From 2fa2e60170f6fa40e21ffe9b84121a71703a342e Mon Sep 17 00:00:00 2001 From: taobig Date: Wed, 26 Aug 2026 05:46:15 -0400 Subject: [PATCH] feat(goal): persist continuous execution across bounded turns - Add a database-backed supervisor with durable scheduling, fenced leases, cooldowns, bounded worker concurrency and expired-lease restart recovery. - Default new goals to persistent execution with zero meaning unlimited cumulative budget; preserve legacy goals and explicit positive limits. - Yield bounded graph segments to the supervisor instead of ending unfinished goals at graph-local continuation limits. Require persisted checklist evidence before accepting completion, including concurrent criterion edits. - Share conversation admission across interactive and background execution; preserve partial replies, usage and queued user input during interruption. - Persist Stop and missing-input pauses, respect approval boundaries, and commit resume and approval-denial transitions with correct transactions. - Retry identifiable transient failures with backoff; retain visible pauses for budget limits and errors that require review instead of replaying tools. - Expose owner-authorized execution status and reconnectable scheduling events; add H2, MySQL and Kingbase migrations, API types and bilingual documentation. Validation: 298 focused backend tests passed, including persistence, restart scheduling, approval races, cancellation, admission and existing runtime tests. Frontend type checking, bundled-doc parity and ID precision checks passed. V188 is registered and all three dialects have unique migration versions; the migration-map audit still reports 95 pre-existing missing registrations. Scope: single-backend native runtime. Recovery checks existing state before repeating effects; this does not promise exactly-once external tool execution. --- .../java/vip/mate/agent/AgentService.java | 38 ++- .../binding/service/AgentBindingService.java | 1 + .../context/GoalContinuationContext.java | 14 + .../agent/graph/node/GoalEvaluationNode.java | 21 ++ .../agent/runtime/ConversationTurnGate.java | 36 ++ .../vip/mate/channel/web/ChatController.java | 30 +- .../mate/channel/web/ChatStreamTracker.java | 46 ++- .../vip/mate/goal/config/GoalProperties.java | 3 + .../controller/GoalExecutionController.java | 30 ++ .../goal/model/GoalContinuationDecision.java | 8 + .../mate/goal/model/GoalCreateRequest.java | 7 +- .../java/vip/mate/goal/model/GoalEntity.java | 5 +- .../vip/mate/goal/model/GoalResponse.java | 3 + .../mate/goal/model/GoalUpdateRequest.java | 3 + .../goal/service/GoalContinuationStore.java | 123 +++++++ .../service/GoalContinuationSupervisor.java | 221 ++++++++++++ .../goal/service/GoalExecutionSignal.java | 9 + .../goal/service/GoalFollowupService.java | 174 ++++++---- .../mate/goal/service/GoalSegmentRunner.java | 217 ++++++++++++ .../vip/mate/goal/service/GoalService.java | 3 + .../mate/goal/service/GoalServiceImpl.java | 129 +++++-- .../mate/tool/builtin/DelegateAgentTool.java | 1 + .../mate/tool/builtin/GoalManagementTool.java | 42 ++- .../migration/h2/V188__goal_continuation.sql | 14 + .../kingbase/V188__goal_continuation.sql | 14 + .../mysql/V188__goal_continuation.sql | 14 + .../src/main/resources/docs/en/goals.md | 31 +- .../src/main/resources/docs/zh/goals.md | 33 +- .../agent/AgentServiceTurnAdmissionTest.java | 51 +++ .../GoalEvaluationNodeContinuationTest.java | 12 + .../runtime/ConversationTurnGateTest.java | 35 ++ .../web/ChatControllerWorkerReadOnlyTest.java | 152 +++++++++ .../ChatStreamTrackerDetachSemanticsTest.java | 9 + .../web/ChatStreamTrackerEventIdTest.java | 18 + .../goal/GoalPersistenceIntegrationTest.java | 86 ++++- .../GoalExecutionControllerTest.java | 27 ++ .../service/GoalContinuationStoreTest.java | 131 ++++++++ .../GoalContinuationSupervisorTest.java | 102 ++++++ .../goal/service/GoalFollowupServiceTest.java | 147 +++++++- .../goal/service/GoalSegmentRunnerTest.java | 193 +++++++++++ .../mate/goal/service/GoalServiceTest.java | 318 +++++++++++++++++- .../DelegateAgentToolDenyListTest.java | 1 + .../tool/builtin/GoalManagementToolTest.java | 53 +++ mateclaw-ui/src/api/index.ts | 3 + 44 files changed, 2473 insertions(+), 135 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/context/GoalContinuationContext.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/ConversationTurnGate.java create mode 100644 mateclaw-server/src/main/java/vip/mate/goal/controller/GoalExecutionController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/goal/model/GoalContinuationDecision.java create mode 100644 mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationStore.java create mode 100644 mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationSupervisor.java create mode 100644 mateclaw-server/src/main/java/vip/mate/goal/service/GoalExecutionSignal.java create mode 100644 mateclaw-server/src/main/java/vip/mate/goal/service/GoalSegmentRunner.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V188__goal_continuation.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V188__goal_continuation.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V188__goal_continuation.sql create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/AgentServiceTurnAdmissionTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/ConversationTurnGateTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/goal/controller/GoalExecutionControllerTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationStoreTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationSupervisorTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/goal/service/GoalSegmentRunnerTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index 555ab058..0d83c1a6 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -72,6 +72,9 @@ public class AgentService { @Autowired(required = false) private vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry; + @Autowired + private vip.mate.agent.runtime.ConversationTurnGate turnGate = new vip.mate.agent.runtime.ConversationTurnGate(); + /** * Optional — clears leftover auto-recorded ledger entries when a new * user turn starts. Field-injected so existing test constructors of @@ -297,6 +300,8 @@ public class AgentService { * work already done before the pause. */ private void clearAutoRecordedForNewTurn(String conversationId) { + // Autonomous segments resume the same objective; retain authoritative tool progress. + if (vip.mate.agent.context.GoalContinuationContext.active()) return; if (progressLedgerService == null || conversationId == null || conversationId.isBlank()) { return; } @@ -663,6 +668,13 @@ public class AgentService { */ private String withLifecycleSync(Long agentId, String message, String conversationId, java.util.function.BiFunction invoke) { + try (var permit = acquireTurn(conversationId)) { + return invokeWithLifecycleSync(agentId,message,conversationId,invoke); + } + } + + private String invokeWithLifecycleSync(Long agentId, String message, String conversationId, + java.util.function.BiFunction invoke) { safeRegister(conversationId, agentId); try { if (!memoryProperties.isLifecycleMediatorEnabled()) { @@ -691,11 +703,26 @@ public class AgentService { private Flux withLifecycleFlux(Long agentId, String message, String conversationId, java.util.function.BiFunction> invoke, Function contentExtractor) { + return Flux.using(() -> acquireTurn(conversationId), + permit -> invokeWithLifecycleFlux(agentId,message,conversationId,invoke,contentExtractor), + vip.mate.agent.runtime.ConversationTurnGate.Permit::close); + } + + private vip.mate.agent.runtime.ConversationTurnGate.Permit acquireTurn(String conversationId) { + var permit = turnGate.tryAcquire(conversationId); + if (permit == null) throw new MateClawException("err.agent.conversation_busy",409,"Conversation is already running"); + return permit; + } + + private Flux invokeWithLifecycleFlux(Long agentId, String message, String conversationId, + java.util.function.BiFunction> invoke, + Function contentExtractor) { + boolean goalContinuation = vip.mate.agent.context.GoalContinuationContext.active(); safeRegister(conversationId, agentId); try { if (!memoryProperties.isLifecycleMediatorEnabled()) { return invoke.apply(message, conversationId) - .doFinally(s -> safeUnregister(conversationId)); + .doFinally(s -> safeUnregister(conversationId, goalContinuation)); } String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get()); TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey); @@ -711,11 +738,11 @@ public class AgentService { }) .doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString())) .doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage())) - .doFinally(s -> safeUnregister(conversationId)); + .doFinally(s -> safeUnregister(conversationId, goalContinuation)); } catch (Exception e) { // If invoke.apply() throws before the Flux is constructed, the // doFinally above never runs — clean up here. - safeUnregister(conversationId); + safeUnregister(conversationId, goalContinuation); throw e; } } @@ -729,9 +756,14 @@ public class AgentService { /** C5 helper — null-safe unregister so tests without the registry don't NPE. */ private void safeUnregister(String conversationId) { + safeUnregister(conversationId, vip.mate.agent.context.GoalContinuationContext.active()); + } + + private void safeUnregister(String conversationId, boolean goalContinuation) { if (runningConversationRegistry != null) { runningConversationRegistry.unregister(conversationId); } + if (events != null && !goalContinuation) events.publishEvent(new vip.mate.goal.service.GoalExecutionSignal.TurnFinished(conversationId)); } private boolean isDshAgent(Long agentId) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index 0abe5bb2..2596faec 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -730,6 +730,7 @@ public class AgentBindingService implements AgentBindingResolver { "addGoalCriterion", "completeGoal", "getGoalStatus", + "waitForGoalInput", // Conversation-scoped progress ledger — same rationale as the // goal primitives above. Long multi-step research / drafting // tasks need it on every business agent, not just the planner, diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/GoalContinuationContext.java b/mateclaw-server/src/main/java/vip/mate/agent/context/GoalContinuationContext.java new file mode 100644 index 00000000..0d2cb810 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/GoalContinuationContext.java @@ -0,0 +1,14 @@ +package vip.mate.agent.context; + +/** Subscription-time marker; callers capture it before asynchronous lifecycle callbacks. */ +public final class GoalContinuationContext { + private static final ThreadLocal ACTIVE = new ThreadLocal<>(); + private GoalContinuationContext() {} + public static boolean active() { return Boolean.TRUE.equals(ACTIVE.get()); } + public static T call(java.util.function.Supplier action) { + Boolean previous=ACTIVE.get(); + ACTIVE.set(true); + try { return action.get(); } + finally { if(previous==null) ACTIVE.remove(); else ACTIVE.set(previous); } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java index 042b35fc..ba2d8f01 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java @@ -184,6 +184,12 @@ public class GoalEvaluationNode implements NodeAction { .build(); } + if (Boolean.TRUE.equals(refreshed.getPersistentExecution()) + && refreshed.getStatus()!=vip.mate.goal.model.GoalStatus.ACTIVE) { + return MateClawStateAccessor.output().goalEvaluatedThisRun(true) + .events(List.of(skippedEvent(refreshed.getId(), "goal_no_longer_active"))).build(); + } + // Decision branches. Each terminal write is wrapped so a DB hiccup // (e.g. optimistic-lock conflict exceeding retries, memory sync // failure on completion) does not propagate into the chat graph @@ -229,6 +235,21 @@ public class GoalEvaluationNode implements NodeAction { .build(); } + // A persistent goal yields a finite segment. Its durable supervisor owns + // the next turn, cooldown and recovery; never consume graph recursion here. + if (Boolean.TRUE.equals(refreshed.getPersistentExecution())) { + return MateClawStateAccessor.output() + .goalEvaluationResult(result.toMap()) + .goalEvaluatedThisRun(true) + .events(List.of(goalEvent("goal_evaluated", Map.of( + "goalId", String.valueOf(refreshed.getId()), + "score", result.score(), + "decision", result.decision(), + "gap", result.gap() == null ? "" : result.gap(), + "goal", goalService.toResponse(refreshed))))) + .build(); + } + int followupCountThisRun = accessor.goalFollowupCount(); int hardContinuationCount = accessor.goalHardContinuationCount(); int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(), diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/ConversationTurnGate.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/ConversationTurnGate.java new file mode 100644 index 00000000..020862e6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/ConversationTurnGate.java @@ -0,0 +1,36 @@ +package vip.mate.agent.runtime; + +import org.springframework.stereotype.Component; +import java.util.concurrent.ConcurrentHashMap; + +/** Atomic local admission shared by interactive, replay and autonomous turns. */ +@Component +public class ConversationTurnGate { + private final ConcurrentHashMap owners = new ConcurrentHashMap<>(); + private final ThreadLocal admitted = new ThreadLocal<>(); + + public Permit tryAcquire(String conversationId) { + if (conversationId == null || conversationId.isBlank()) return new Permit(null); + Permit current = admitted.get(); + if (current != null && conversationId.equals(current.conversationId) + && owners.get(conversationId) == current) return new Permit(null); + Permit permit = new Permit(conversationId); + return owners.putIfAbsent(conversationId, permit) == null ? permit : null; + } + + /** Enter the already-admitted call synchronously; inner lifecycle cleanup must not release its owner. */ + public T withPermit(Permit permit, java.util.function.Supplier call) { + Permit previous=admitted.get(); + admitted.set(permit); + try { return call.get(); } + finally { if (previous==null) admitted.remove(); else admitted.set(previous); } + } + + public final class Permit implements AutoCloseable { + private final String conversationId; + private Permit(String conversationId) { this.conversationId = conversationId; } + @Override public void close() { + if (conversationId != null) owners.remove(conversationId, this); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 10af9dbe..e57e5704 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -19,6 +19,7 @@ import vip.mate.common.result.R; import vip.mate.workspace.core.service.ChatUploadLocationResolver; import vip.mate.agent.AgentService; import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.runtime.ConversationTurnGate; import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.MetadataDecision; import vip.mate.approval.PendingApproval; @@ -67,6 +68,9 @@ public class ChatController { private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver; private final vip.mate.tool.document.preview.OfficePreviewService officePreviewService; + @org.springframework.beans.factory.annotation.Autowired + private ConversationTurnGate turnGate = new ConversationTurnGate(); + // Virtual thread per SSE task: matches the app-wide virtual-thread model // (spring.threads.virtual.enabled=true) and, unlike a cached platform-thread // pool, never reuses a thread across tasks, so no ThreadLocal state can leak @@ -200,6 +204,21 @@ public class ChatController { return emitter; } + if (conversationService.conversationExists(conversationId) + && !conversationService.isConversationOwner(conversationId, username)) { + sendErrorDoneAndComplete(emitter, "无权操作该会话"); + return emitter; + } + + // Reserve before approval consumption, regeneration or stream mutation. + // Once registered, RunState protects the setup-to-subscription gap: + // autonomous admission checks isRunning while holding this same gate. + try (var setupPermit = turnGate.tryAcquire(conversationId)) { + if (setupPermit == null || streamTracker.isRunning(conversationId)) { + sendErrorDoneAndComplete(emitter, "正在生成回复,请先停止或排队后续消息"); + return emitter; + } + // ---- 审批命令拦截:/approve、/deny 走 SSE 流式 replay ---- String normalizedMsg = requestMessage.trim().toLowerCase(); boolean isApprovalCommand = "/approve".equals(normalizedMsg) || "approve".equals(normalizedMsg); @@ -249,6 +268,7 @@ public class ChatController { final String decision = isApprovalCommand ? "approved" : "denied"; streamTracker.register(conversationId); + setupPermit.close(); Long approvalAgentId = parseLongOrNull(pending.getAgentId()); streamTracker.bindRunMeta(conversationId, approvalAgentId, username); registerEmitterCallbacks(emitter, conversationId); @@ -555,6 +575,7 @@ public class ChatController { // ---- 正常请求:注册流状态并附着首个订阅者 ---- streamTracker.register(conversationId); + setupPermit.close(); streamTracker.bindRunMeta(conversationId, agentId, username); registerEmitterCallbacks(emitter, conversationId); streamTracker.attach(conversationId, emitter); @@ -1005,6 +1026,7 @@ public class ChatController { }); return emitter; + } } /** @@ -1123,6 +1145,10 @@ public class ChatController { if (username == null) { return R.fail(401, "未登录,请先登录"); } + try (var permit = turnGate.tryAcquire(request.getConversationId())) { + if (permit == null || streamTracker.isRunning(request.getConversationId())) { + return R.fail(409, "正在生成回复,请先停止或排队后续消息"); + } conversationService.getOrCreateConversation(request.getConversationId(), agentId, username, workspaceId); MessageEntity savedUser = conversationService.saveMessage( request.getConversationId(), "user", request.getMessage(), request.getContentParts()); @@ -1134,7 +1160,8 @@ public class ChatController { memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId, request.getEndUserId()).withOriginMessageId( savedUser == null ? null : savedUser.getId()); - AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin); + AgentService.ChatResult result = turnGate.withPermit(permit, () -> + agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin)); String response = result.content(); conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed", result.promptTokens(), result.completionTokens(), @@ -1142,6 +1169,7 @@ public class ChatController { completionPublisher.publish(agentId, request.getConversationId(), request.getMessage(), response, "web", memoryOwnerResolver.resolve(webOrigin)); return R.ok(response); + } } @Operation(summary = "上传聊天附件") diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java index 1c81a55d..d8c1b90c 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java @@ -654,8 +654,32 @@ public class ChatStreamTracker { * 取消 Flux 订阅(底层 HTTP 连接也会随之关闭),返回 true 表示确实停止了正在运行的流。 */ public boolean requestStop(String conversationId) { - RunState state = runs.get(conversationId); + // A goal may be between finite segments, with no live RunState to cancel. + // Persist the user's intent before looking up that ephemeral state. + try { + if (applicationContext != null) { + applicationContext.publishEvent(new vip.mate.goal.service.GoalExecutionSignal.Stop(conversationId)); + } + } catch (RuntimeException persistenceFailure) { + // Still cancel live work, but do not acknowledge a durable Stop that failed. + requestStopLive(conversationId); + throw persistenceFailure; + } + return requestStopLive(conversationId); + } + + private boolean requestStopLive(String conversationId) { + return requestStopLive(runs.get(conversationId)); + } + + /** Cancel only this generation, without publishing a new user Stop intent. */ + public boolean cancelRun(RunHandle handle) { + return handle != null && requestStopLive(handle.state); + } + + private boolean requestStopLive(RunState state) { if (state == null) return false; + String conversationId = state.conversationId; final boolean firstRequest; final Disposable d; @@ -738,11 +762,13 @@ public class ChatStreamTracker { /** * 广播事件到所有订阅者并缓存到 buffer. *

- * Two event categories survive {@code state.done=true}: + * Lifecycle event categories survive {@code state.done=true}: *

    *
  • {@code "done"} — the lifecycle marker itself. If a client missed * this on a broken pipe and reconnects within the 5-minute retention * window, replay surfaces it so the UI exits "生成中" state.
  • + *
  • {@code "goal_continuation"} — durable scheduling is settled after + * the graph segment completes, and remains available on reconnect.
  • *
  • {@code "async_task_*"} — task lifecycle events from * {@code AsyncTaskService} (image/video/music generation). These * routinely fire after the agent's reasoning turn finishes @@ -764,7 +790,8 @@ public class ChatStreamTracker { if (handle == null) return; RunState state = handle.state; boolean isDone = "done".equals(eventName); - boolean isAsyncTask = eventName != null && eventName.startsWith("async_task_"); + boolean isPostTurnEvent = "goal_continuation".equals(eventName) + || (eventName != null && eventName.startsWith("async_task_")); boolean isHeartbeat = "heartbeat".equals(eventName); List targets; long eventId = 0L; @@ -775,10 +802,10 @@ public class ChatStreamTracker { if (!isHeartbeat) { state.lastEventAt = System.currentTimeMillis(); } - if (!isDone && !isAsyncTask && !isHeartbeat && state.done) { + if (!isDone && !isPostTurnEvent && !isHeartbeat && state.done) { return; } - if ((isDone || isAsyncTask) || (!isHeartbeat && !skipBuffer)) { + if ((isDone || isPostTurnEvent) || (!isHeartbeat && !skipBuffer)) { eventId = EVENT_IDS.nextId(); state.buffer.add(new SseEvent(eventId, eventName, jsonData)); if (state.buffer.size() > MAX_BUFFER_SIZE) { @@ -786,7 +813,7 @@ public class ChatStreamTracker { } } targets = new ArrayList<>(state.subscribers); - forwardRelays = !isDone && !isAsyncTask && !isHeartbeat; + forwardRelays = !isDone && !isPostTurnEvent && !isHeartbeat; } List dead = new ArrayList<>(); @@ -840,7 +867,8 @@ public class ChatStreamTracker { RunState state = runs.get(conversationId); boolean isDone = "done".equals(eventName); - boolean isAsyncTask = eventName != null && eventName.startsWith("async_task_"); + boolean isPostTurnEvent = "goal_continuation".equals(eventName) + || (eventName != null && eventName.startsWith("async_task_")); boolean isHeartbeat = "heartbeat".equals(eventName); // Stamp last activity for stuck detection. Heartbeats are excluded @@ -850,7 +878,7 @@ public class ChatStreamTracker { state.lastEventAt = System.currentTimeMillis(); } - if (isDone || isAsyncTask) { + if (isDone || isPostTurnEvent) { if (state == null) return; synchronized (state.lock) { long id = EVENT_IDS.nextId(); @@ -874,7 +902,7 @@ public class ChatStreamTracker { } } } - // done events do not flow through eventRelays; async_task_* should + // done events do not flow through eventRelays; post-turn events should // also short-circuit since relays exist for delta-style streaming // events, not lifecycle markers. return; diff --git a/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java b/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java index 6909e9ef..4e075a98 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java @@ -41,6 +41,9 @@ public class GoalProperties { */ private boolean defaultAutoFollowup = true; + /** Create-time default only; existing goals retain their persisted mode. */ + private boolean defaultPersistentExecution = true; + /** * Runtime hard gate for auto-followup. When false, no goal injects a * follow-up regardless of its per-goal {@code autoFollowupEnabled} flag — diff --git a/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalExecutionController.java b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalExecutionController.java new file mode 100644 index 00000000..c56e3cb0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalExecutionController.java @@ -0,0 +1,30 @@ +package vip.mate.goal.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; +import vip.mate.goal.service.GoalContinuationStore; +import vip.mate.goal.service.GoalService; +import vip.mate.workspace.conversation.ConversationService; + +/** Durable execution status, separate from goal acceptance status. */ +@RestController +@RequiredArgsConstructor +public class GoalExecutionController { + private final GoalService goals; + private final GoalContinuationStore store; + private final ConversationService conversations; + + @GetMapping("/api/v1/goals/{id}/execution") + public R execution(@PathVariable Long id, Authentication auth) { + var goal=goals.getById(id); + if (auth==null || !conversations.isConversationOwner(goal.getConversationId(),auth.getName())) { + throw new MateClawException("err.goal.forbidden",403,"Not the conversation owner"); + } + return R.ok(store.get(id)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalContinuationDecision.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalContinuationDecision.java new file mode 100644 index 00000000..6b8a32bf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalContinuationDecision.java @@ -0,0 +1,8 @@ +package vip.mate.goal.model; + +import java.time.LocalDateTime; + +/** Explicit continuation outcome shared by graph compatibility and durable scheduling. */ +public record GoalContinuationDecision(Action action, String prompt, LocalDateTime nextRunAt, String reason) { + public enum Action { CONTINUE, DEFER, DISABLED, COMPLETE, BUDGET_LIMITED, RETRY } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java index fc084d98..78f884bf 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java @@ -8,8 +8,8 @@ import java.util.List; * Request body for {@code POST /api/v1/goals}. * *

    Only {@code conversationId}, {@code agentId}, {@code workspaceId} and - * {@code title} are mandatory. Budgets default to the values in - * {@link vip.mate.goal.config.GoalProperties}. + * {@code title} are mandatory. Persistent goals default to unlimited budgets + * (zero); legacy goals use {@link vip.mate.goal.config.GoalProperties} defaults. * *

    ID fields stay as {@code Long} on the wire (Jackson accepts both * numeric and string forms via the project's default coercion), but the @@ -28,6 +28,9 @@ public class GoalCreateRequest { private String exitCriteria; private String successCheckPrompt; + /** Opts into durable continuation; zero budgets mean unlimited only in this mode. */ + private Boolean persistentExecution; + private Integer turnBudget; private Integer llmCallBudget; private Boolean autoFollowupEnabled; diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java index bbff046e..a1c89e46 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java @@ -67,7 +67,10 @@ public class GoalEntity { */ private GoalStatus status; - /** Maximum evaluation turns before exhaustion. */ + /** Opts into durable continuation; zero budgets mean unlimited only in this mode. */ + private Boolean persistentExecution; + + /** Maximum evaluation turns; zero is unlimited only for persistent execution. */ private Integer turnBudget; /** Cumulative turns evaluated; bumped by GoalEvaluationNode. */ diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java index ea272710..d45f4ecf 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java @@ -30,6 +30,9 @@ public class GoalResponse { private GoalStatus status; + /** Opts into durable continuation; zero budgets mean unlimited only in this mode. */ + private Boolean persistentExecution; + private Integer turnBudget; private Integer turnsUsed; private Integer llmCallBudget; diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalUpdateRequest.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalUpdateRequest.java index 1f6b1f5f..5d0a9d33 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalUpdateRequest.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalUpdateRequest.java @@ -18,6 +18,9 @@ public class GoalUpdateRequest { private String exitCriteria; private String successCheckPrompt; + /** Opts into durable continuation; zero budgets mean unlimited only in this mode. */ + private Boolean persistentExecution; + private Integer turnBudget; private Integer llmCallBudget; private Boolean autoFollowupEnabled; diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationStore.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationStore.java new file mode 100644 index 00000000..2fd12dc5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationStore.java @@ -0,0 +1,123 @@ +package vip.mate.goal.service; + +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; + +/** Durable scheduling state. Every worker write is fenced by its unique lease token. */ +@Repository +public class GoalContinuationStore { + private final JdbcTemplate jdbc; + private static final String ELIGIBLE = """ + g.status='active' AND g.deleted=0 AND g.persistent_execution=TRUE + AND g.auto_followup_enabled=TRUE + """; + private static final String DUE = """ + ((c.state IN ('queued','retry') AND c.next_run_at<=?) + OR (c.state='running' AND c.lease_until<=?)) + """; + + public GoalContinuationStore(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + public record Continuation(Long goalId, String conversationId, String state, + LocalDateTime nextRunAt, String leaseOwner, + LocalDateTime leaseUntil, int failures, String reason) {} + + public void discover(LocalDateTime now) { + // Bounded discovery; another instance may insert the same goal concurrently. + List ids = jdbc.queryForList(""" + SELECT g.id FROM mate_agent_goal g WHERE + """ + ELIGIBLE + """ + AND NOT EXISTS(SELECT 1 FROM mate_goal_continuation c WHERE c.goal_id=g.id) + ORDER BY g.id LIMIT 100 + """, Long.class); + for (Long id : ids) { + try { + jdbc.update(""" + INSERT INTO mate_goal_continuation(goal_id,state,next_run_at,failures,reason,updated_at) + VALUES(?,'queued',?,0,'goal_active',?) + """, id, now, now); + } catch (DuplicateKeyException ignored) { /* the other instance owns discovery */ } + } + } + + public List due(LocalDateTime now, int limit) { + return jdbc.query(""" + SELECT c.*,g.conversation_id FROM mate_goal_continuation c + JOIN mate_agent_goal g ON g.id=c.goal_id WHERE + """ + ELIGIBLE + " AND " + DUE + " ORDER BY c.next_run_at,c.goal_id LIMIT ?", + (rs, row) -> read(rs), now, now, Math.max(1, Math.min(limit, 100))); + } + + public Continuation get(Long goalId) { + List rows = jdbc.query(""" + SELECT c.*,g.conversation_id FROM mate_goal_continuation c + JOIN mate_agent_goal g ON g.id=c.goal_id WHERE c.goal_id=? + """, (rs, row) -> read(rs), goalId); + return rows.isEmpty() ? null : rows.getFirst(); + } + + public boolean claim(Long goalId, String token, LocalDateTime now, LocalDateTime until) { + return jdbc.update(""" + UPDATE mate_goal_continuation SET state='running',lease_owner=?,lease_until=?,updated_at=?,wake_requested=FALSE + WHERE goal_id=? AND + ((state IN ('queued','retry') AND next_run_at<=?) + OR (state='running' AND lease_until<=?)) + AND EXISTS(SELECT 1 FROM mate_agent_goal g WHERE g.id=goal_id AND + """ + ELIGIBLE + ")", token, until, now, goalId, now, now) == 1; + } + + public boolean renew(Long goalId, String token, LocalDateTime until) { + return jdbc.update(""" + UPDATE mate_goal_continuation SET lease_until=? + WHERE goal_id=? AND lease_owner=? AND state='running' + """, until, goalId, token) == 1; + } + + public boolean settle(Long goalId, String token, String state, LocalDateTime nextRunAt, + int failures, String reason) { + return jdbc.update(""" + UPDATE mate_goal_continuation SET state=CASE WHEN ?='waiting_approval' AND wake_requested=TRUE THEN 'queued' ELSE ? END, + next_run_at=?,failures=?,reason=?,wake_requested=FALSE,lease_owner=NULL,lease_until=NULL,updated_at=? + WHERE goal_id=? AND lease_owner=? AND state='running' + """, state, state, nextRunAt, failures, bounded(reason), LocalDateTime.now(), goalId, token) == 1; + } + + public void suspendConversation(String conversationId, String reason) { + jdbc.update(""" + UPDATE mate_goal_continuation SET state='paused',reason=?,lease_owner=NULL,lease_until=NULL, + updated_at=? WHERE goal_id IN (SELECT id FROM mate_agent_goal WHERE conversation_id=?) + """, bounded(reason), LocalDateTime.now(), conversationId); + } + + public void resume(Long goalId, LocalDateTime now) { + jdbc.update(""" + UPDATE mate_goal_continuation SET state='queued',next_run_at=?,failures=0,reason='resumed', + lease_owner=NULL,lease_until=NULL,updated_at=? WHERE goal_id=? AND state<>'running' + """, now, now, goalId); + } + + public void turnFinished(String conversationId, LocalDateTime now) { + jdbc.update(""" + UPDATE mate_goal_continuation SET state=CASE WHEN state='waiting_approval' THEN 'queued' ELSE state END, + wake_requested=TRUE,next_run_at=?,reason='interactive_turn_finished',updated_at=? + WHERE state IN ('waiting_approval','running') AND goal_id IN + (SELECT id FROM mate_agent_goal WHERE conversation_id=? AND status='active' AND deleted=0) + """,now,now,conversationId); + } + + private static Continuation read(java.sql.ResultSet rs) throws java.sql.SQLException { + Timestamp until = rs.getTimestamp("lease_until"); + return new Continuation(rs.getLong("goal_id"), rs.getString("conversation_id"), rs.getString("state"), + rs.getTimestamp("next_run_at").toLocalDateTime(), rs.getString("lease_owner"), + until == null ? null : until.toLocalDateTime(), rs.getInt("failures"), rs.getString("reason")); + } + + private static String bounded(String text) { + return text == null ? "" : text.substring(0, Math.min(1000, text.length())); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationSupervisor.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationSupervisor.java new file mode 100644 index 00000000..0611f2fb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationSupervisor.java @@ -0,0 +1,221 @@ +package vip.mate.goal.service; + +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import vip.mate.agent.runtime.RunningConversationRegistry; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalStatus; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** Owns cross-turn liveness. Graph recursion limits bound segments, not goal lifetime. */ +@Slf4j +@Component +public class GoalContinuationSupervisor { + private final GoalContinuationStore store; + private final GoalService goals; + private final GoalProperties properties; + private final GoalFollowupService followups; + private final GoalSegmentRunner runner; + private final RunningConversationRegistry running; + private final ChatStreamTracker streams; + private final Clock clock; + private final Executor executor; + private final ConcurrentHashMap active = new ConcurrentHashMap<>(); + private volatile boolean closing; + + @Autowired + public GoalContinuationSupervisor(GoalContinuationStore store, GoalService goals, GoalProperties properties, + GoalFollowupService followups, GoalSegmentRunner runner, RunningConversationRegistry running, + ChatStreamTracker streams) { + this(store,goals,properties,followups,runner,running,streams,Clock.systemDefaultZone(), + Executors.newVirtualThreadPerTaskExecutor()); + } + + GoalContinuationSupervisor(GoalContinuationStore store, GoalService goals, GoalProperties properties, + GoalFollowupService followups, GoalSegmentRunner runner, RunningConversationRegistry running, + ChatStreamTracker streams, Clock clock, Executor executor) { + this.store=store; this.goals=goals; this.properties=properties; this.followups=followups; + this.runner=runner; this.running=running; this.streams=streams; this.clock=clock; this.executor=executor; + } + + @Scheduled(fixedDelayString="${mateclaw.goal.supervisor-poll-ms:5000}", initialDelayString="${mateclaw.goal.supervisor-poll-ms:5000}") + public void tick() { + if (closing || !properties.isEnabled() || !properties.isAllowAutoFollowup()) return; + LocalDateTime now = LocalDateTime.now(clock); + active.forEach((id, token) -> { + GoalEntity goal = goals.getById(id); + boolean cancelled = goal.getStatus()==GoalStatus.PAUSED || goal.getStatus()==GoalStatus.ABANDONED + || !Boolean.TRUE.equals(goal.getAutoFollowupEnabled()); + if (cancelled || !store.renew(id, token, now.plusSeconds(60))) runner.cancel(id); + }); + store.discover(now); + for (var candidate : store.due(now, 20)) { + if (active.size() >= 4) break; + String conv = candidate.conversationId(); + if (active.containsKey(candidate.goalId()) || running.isActive(conv) + || streams.isRunning(conv)) continue; + GoalEntity goal = goals.getById(candidate.goalId()); + if (!eligible(goal)) continue; + String token = UUID.randomUUID().toString(); + if (active.putIfAbsent(goal.getId(),token) != null) continue; + try { + if (!store.claim(goal.getId(),token,now,now.plusSeconds(60))) { + active.remove(goal.getId(),token); continue; + } + executor.execute(() -> execute(goal, candidate, token)); + } catch (RuntimeException error) { + active.remove(goal.getId(),token); + store.settle(goal.getId(),token,"retry",now.plusSeconds(5),candidate.failures()+1,"dispatch_failed"); + log.warn("Goal {} dispatch failed",goal.getId(),error); + } + } + } + + private void execute(GoalEntity initial, GoalContinuationStore.Continuation candidate, String token) { + LocalDateTime now = LocalDateTime.now(clock); + try { + GoalEntity goal = goals.getById(initial.getId()); + if (!eligible(goal)) { + settle(initial,token,"paused",now,0,"goal_not_runnable"); return; + } + var decision = followups.decide(goal,new GoalEvaluationResult(0,goal.getProgressSummary(), + GoalEvaluationResult.DECISION_CONTINUE,false,"",0,0,List.of(),null),now); + switch (decision.action()) { + case DEFER, RETRY -> { + settle(goal,token,"queued",decision.nextRunAt(),candidate.failures(),decision.reason()); return; + } + case BUDGET_LIMITED -> { + goals.markExhausted(goal.getId(),decision.reason()); + settle(goal,token,"budget_limited",now,0,decision.reason()); return; + } + case COMPLETE, DISABLED -> { + settle(goal,token,"paused",now,0,decision.reason()); return; + } + case CONTINUE -> { } + } + GoalSegmentRunner.Result result = runner.run(goal,decision.prompt(),"running".equals(candidate.state())); + GoalEntity fresh = goals.getById(goal.getId()); + if (fresh.getStatus() == GoalStatus.COMPLETED) { + settle(goal,token,"completed",now,0,"goal_completed"); + } else if (fresh.getStatus() == GoalStatus.PAUSED && goals.isBudgetExhausted(fresh)) { + settle(goal,token,"budget_limited",now,0,goals.exhaustionReason(fresh)); + } else if (!eligible(fresh) || "stopped".equals(result.finishReason())) { + boolean waiting = fresh.getProgressSummary()!=null && fresh.getProgressSummary().startsWith("Waiting for input:"); + settle(goal,token,waiting ? "waiting_input" : "paused",now,0, + waiting ? fresh.getProgressSummary() : "goal_paused_or_stopped"); + } else if (result.awaitingApproval()) { + settle(goal,token,"waiting_approval",now.plusSeconds(5),0,"approval_required"); + } else if ("error_fallback".equals(result.finishReason())) { + // The graph discarded the original error category; do not blindly replay tools. + goals.pause(goal.getId(),goal.getCreatedBy()); + settle(goal,token,"blocked",now,candidate.failures()+1,"graph_error_requires_review"); + } else if (result.evaluationUnavailable()) { + settle(goal,token,"retry",now.plusSeconds(30),candidate.failures()+1,"evaluation_unavailable"); + } else { + int cooldown = fresh.getFollowupCooldownSeconds() == null ? 0 : fresh.getFollowupCooldownSeconds(); + settle(goal,token,"queued",LocalDateTime.now(clock).plusSeconds(Math.max(1,cooldown)),0,"unfinished"); + } + } catch (RuntimeException error) { + // A shutdown/lost-lease cancellation is not a task failure. Leave the + // running lease for restart recovery; the runner saves partial evidence. + if (closing || Thread.currentThread().isInterrupted()) return; + int failures = Math.min(1000,candidate.failures()+1); + boolean transientError = retryable(error); + if (!transientError) { + GoalEntity fresh=goals.getById(initial.getId()); + if (eligible(fresh)) goals.pause(fresh.getId(),fresh.getCreatedBy()); + } + long delay = Math.min(300,5L << Math.min(6,failures-1)); + settle(initial,token,transientError ? "retry" : "blocked",now.plusSeconds(delay),failures, + transientError ? "transient_provider_error" : "execution_requires_review"); + log.warn("Goal {} segment failed ({})",initial.getId(),transientError ? "retry" : "blocked",error); + } finally { + active.remove(initial.getId(),token); + } + } + + private void settle(GoalEntity goal, String token, String state, LocalDateTime due, int failures, String reason) { + if (store.settle(goal.getId(),token,state,due,failures,reason)) { + streams.broadcastObject(goal.getConversationId(),"goal_continuation",store.get(goal.getId())); + } + } + + private static boolean eligible(GoalEntity goal) { + return goal != null && goal.getStatus()==GoalStatus.ACTIVE + && Boolean.TRUE.equals(goal.getPersistentExecution()) && Boolean.TRUE.equals(goal.getAutoFollowupEnabled()); + } + + static boolean retryable(Throwable error) { + for (Throwable e=error; e!=null; e=e.getCause()) { + if (e instanceof java.io.IOException || e instanceof java.util.concurrent.TimeoutException + || e instanceof org.springframework.web.client.ResourceAccessException) return true; + if (e instanceof org.springframework.web.client.RestClientResponseException response) { + int code = response.getStatusCode().value(); + return code==408 || code==429 || code>=500; + } + if (e instanceof vip.mate.exception.MateClawException mate && mate.getCode()==409) return true; + } + return false; + } + + @EventListener + public void stopped(GoalExecutionSignal.Stop event) { + runner.stopConversation(event.conversationId()); + GoalEntity goal = goals.findActiveByConversation(event.conversationId()); + if (goal != null && Boolean.TRUE.equals(goal.getPersistentExecution())) { + runner.cancel(goal.getId()); + goals.pause(goal.getId(),goal.getCreatedBy()); + store.suspendConversation(event.conversationId(),"user_stopped"); + } + } + + @TransactionalEventListener(phase=TransactionPhase.BEFORE_COMMIT, fallbackExecution=true) + public void resumed(GoalExecutionSignal.Resume event) { + store.resume(event.goalId(),LocalDateTime.now(clock)); + } + + @EventListener + public void turnFinished(GoalExecutionSignal.TurnFinished event) { + store.turnFinished(event.conversationId(),LocalDateTime.now(clock)); + } + + @EventListener + @Transactional(propagation=Propagation.REQUIRES_NEW) + public void approvalResolved(vip.mate.approval.event.ApprovalResolutionEvent event) { + if ("denied".equals(event.resolutionNote()) || "TIMEOUT".equals(event.decisionSource())) { + stopped(new GoalExecutionSignal.Stop(event.conversationId())); + } + // Approval execution belongs to the existing replay path. Only its + // TurnFinished event releases waiting_approval; never consume/replay here. + } + + @PreDestroy public void close() { + closing=true; + if (executor instanceof java.util.concurrent.ExecutorService workers) { + workers.shutdownNow(); + try { + if (!workers.awaitTermination(10,java.util.concurrent.TimeUnit.SECONDS)) { + log.warn("Goal workers did not finish shutdown persistence within 10 seconds"); + } + } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalExecutionSignal.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalExecutionSignal.java new file mode 100644 index 00000000..2eb95064 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalExecutionSignal.java @@ -0,0 +1,9 @@ +package vip.mate.goal.service; + +/** Explicit user controls, distinct from a graph segment reaching its limit. */ +public final class GoalExecutionSignal { + private GoalExecutionSignal() {} + public record Stop(String conversationId) {} + public record Resume(Long goalId) {} + public record TurnFinished(String conversationId) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalFollowupService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalFollowupService.java index 2bf5066f..f9573745 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalFollowupService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalFollowupService.java @@ -1,27 +1,25 @@ package vip.mate.goal.service; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalContinuationDecision; +import vip.mate.goal.model.GoalContinuationDecision.Action; import vip.mate.goal.model.GoalCriteriaCodec; import vip.mate.goal.model.GoalCriterion; import vip.mate.goal.model.GoalEntity; import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalStatus; -import java.time.Duration; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; -/** - * Decides whether to inject a follow-up user prompt for the next graph pass, - * driving the autonomous "continue until the checklist is complete" loop. - */ -@Slf4j +/** Shared continuation policy for bounded graph passes and durable scheduling. */ @Service public class GoalFollowupService { + private static final int EVALUATION_RETRY_SECONDS = 30; private final GoalProperties properties; private final ObjectMapper objectMapper; @@ -30,78 +28,118 @@ public class GoalFollowupService { this.objectMapper = objectMapper; } - /** - * Build the follow-up prompt to inject, or empty when no follow-up should - * fire this turn. Gating order: - *

      - *
    1. {@code allow-auto-followup} runtime hard gate (operator kill - * switch; overrides per-goal flag).
    2. - *
    3. Per-goal {@code autoFollowupEnabled}.
    4. - *
    5. Evaluator decision is "continue" (not all criteria passed).
    6. - *
    7. Cooldown since the last follow-up has elapsed.
    8. - *
    9. turn_budget has at least one slot left after this turn.
    10. - *
    11. (agent + eval) LLM calls below 90% of llm_call_budget.
    12. - *
    - */ - public Optional maybeBuildFollowup(GoalEntity goal, - GoalEvaluationResult result) { - if (goal == null || result == null) return Optional.empty(); - // Runtime hard gate first — overrides any per-goal flag. - if (!properties.isAllowAutoFollowup()) return Optional.empty(); - if (!Boolean.TRUE.equals(goal.getAutoFollowupEnabled())) return Optional.empty(); - // Completion is deterministic now: the evaluator sets decision=completed - // only when every checklist criterion passed. Anything still "continue" - // has remaining work regardless of the numeric score, so there is no - // score threshold here — a 20/21 goal (score 0.95) must still follow up. - if (!GoalEvaluationResult.DECISION_CONTINUE.equals(result.decision())) { - return Optional.empty(); + /** Pure decision: callers persist due times and perform state transitions. */ + public GoalContinuationDecision decide(GoalEntity goal, GoalEvaluationResult result, LocalDateTime now) { + if (goal == null) return decision(Action.DISABLED, null, null, "goal_missing"); + if (goal.getStatus() == GoalStatus.COMPLETED) { + return decision(Action.COMPLETE, null, null, "goal_completed"); + } + if (goal.getStatus() != GoalStatus.ACTIVE) { + return decision(Action.DISABLED, null, null, "goal_not_active"); + } + if (!properties.isEnabled() || !properties.isAllowAutoFollowup() + || !Boolean.TRUE.equals(goal.getAutoFollowupEnabled())) { + return decision(Action.DISABLED, null, null, "auto_followup_disabled"); + } + boolean fallback = result == null || GoalEvaluationResult.DECISION_FALLBACK.equals(result.decision()); + // A fallback cannot prove completion, even if a malformed caller sets completed=true. + boolean persistent = Boolean.TRUE.equals(goal.getPersistentExecution()); + boolean claimedComplete = !fallback && (result.completed() + || GoalEvaluationResult.DECISION_COMPLETED.equals(result.decision())); + boolean completionUnverified = claimedComplete && persistent && !hasVerifiedChecklist(goal); + if (claimedComplete && !completionUnverified) { + return decision(Action.COMPLETE, null, null, "criteria_completed"); } - // Cooldown — last_followup_at recorded by recordFollowupInjected(). - Integer cooldownSec = goal.getFollowupCooldownSeconds(); - if (cooldownSec != null && cooldownSec > 0 && goal.getLastFollowupAt() != null) { - Duration since = Duration.between(goal.getLastFollowupAt(), LocalDateTime.now()); - if (since.getSeconds() < cooldownSec) { - log.debug("[GoalFollowup] cooldown not elapsed: {}s < {}s", since.getSeconds(), cooldownSec); - return Optional.empty(); - } - } - - int turnsUsed = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0; + int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0; int turnBudget = goal.getTurnBudget() != null ? goal.getTurnBudget() : Integer.MAX_VALUE; - // Leave at least one turn slot for the real user — refuse to burn the - // final slot on an auto-followup the user can't watch. - if (turnsUsed >= turnBudget - 1) return Optional.empty(); - + if ((persistent && turnBudget != 0 && turns >= turnBudget) + || (!persistent && turns >= turnBudget - 1)) { + return decision(Action.BUDGET_LIMITED, null, null, "turn_budget"); + } int callBudget = goal.getLlmCallBudget() != null ? goal.getLlmCallBudget() : Integer.MAX_VALUE; - if (goal.totalLlmCallsUsed() >= (int) (callBudget * 0.9)) return Optional.empty(); + if ((persistent && callBudget != 0 && goal.totalLlmCallsUsed() >= callBudget) + || (!persistent && goal.totalLlmCallsUsed() >= (int) (callBudget * 0.9))) { + return decision(Action.BUDGET_LIMITED, null, null, "llm_call_budget"); + } - return Optional.of(buildPrompt(goal, result)); + String prompt = buildPrompt(goal, result); + LocalDateTime cooldownDeadline = now; + Integer cooldown = goal.getFollowupCooldownSeconds(); + if (cooldown != null && cooldown > 0 && goal.getLastFollowupAt() != null) { + cooldownDeadline = goal.getLastFollowupAt().plusSeconds(cooldown); + } + if (fallback || completionUnverified || !GoalEvaluationResult.DECISION_CONTINUE.equals(result.decision())) { + LocalDateTime retryAt = now.plusSeconds(EVALUATION_RETRY_SECONDS); + if (cooldownDeadline.isAfter(retryAt)) retryAt = cooldownDeadline; + return decision(Action.RETRY, prompt, retryAt, + completionUnverified ? "completion_not_verified" + : result == null ? "evaluation_missing" : bounded(result.gap(), 1000)); + } + if (cooldownDeadline.isAfter(now)) { + return decision(Action.DEFER, prompt, cooldownDeadline, "followup_cooldown"); + } + return decision(Action.CONTINUE, prompt, now, "remaining_criteria"); } - /** - * Prefer a concrete remaining-criteria list when the goal has a checklist; - * fall back to the free-text gap otherwise. Both end with the same "take - * the next concrete step" instruction. - */ + private boolean hasVerifiedChecklist(GoalEntity goal) { + List checklist = GoalCriteriaCodec.parse(goal.getCriteria(), objectMapper); + return !checklist.isEmpty() && checklist.stream().allMatch(c -> c != null && c.passed() + && c.evidence() != null && !c.evidence().isBlank()); + } + + /** Compatibility wrapper for graph-local followups; deferred/retry work is not injected. */ + public Optional maybeBuildFollowup(GoalEntity goal, GoalEvaluationResult result) { + GoalContinuationDecision decision = decide(goal, result, LocalDateTime.now()); + return decision.action() == Action.CONTINUE ? Optional.of(decision.prompt()) : Optional.empty(); + } + + private GoalContinuationDecision decision(Action action, String prompt, LocalDateTime nextRunAt, String reason) { + return new GoalContinuationDecision(action, prompt, nextRunAt, reason); + } + + /** Bound each evidence section while always retaining recovery/safety instructions. */ private String buildPrompt(GoalEntity goal, GoalEvaluationResult result) { + StringBuilder prompt = new StringBuilder(); + prompt.append("Continue working toward the original objective; preserve its scope and exit criteria.\n") + .append("Title: ").append(bounded(goal.getTitle(), 255)).append('\n') + .append("Objective: ").append(bounded(goal.getDescription(), 2500)).append('\n') + .append("Exit criteria: ").append(bounded(goal.getExitCriteria(), 2000)).append('\n'); List all = GoalCriteriaCodec.parse(goal.getCriteria(), objectMapper); - List remaining = GoalCriteriaCodec.remaining(all); + boolean persistent = Boolean.TRUE.equals(goal.getPersistentExecution()); + List remaining = persistent + ? all.stream().filter(c -> !c.passed() || c.evidence() == null || c.evidence().isBlank()).toList() + : GoalCriteriaCodec.remaining(all); if (!remaining.isEmpty()) { - int total = all.size(); - int passed = total - remaining.size(); - StringBuilder sb = new StringBuilder(); - sb.append("Continue working toward the goal. ") - .append(passed).append('/').append(total).append(" criteria passed. Remaining:\n"); - for (GoalCriterion c : remaining) { - sb.append(" - ").append(c.text()).append('\n'); + prompt.append(all.size() - remaining.size()).append('/').append(all.size()) + .append(persistent ? " criteria verified. Remaining checklist (verify missing evidence):\n" + : " criteria passed. Remaining checklist:\n"); + StringBuilder checklist = new StringBuilder(); + for (GoalCriterion criterion : remaining) { + if (checklist.length() >= 4000) break; + checklist.append(" - ").append(bounded(criterion.text(), 600)).append('\n'); } - sb.append("Take the next concrete step on the remaining criteria."); - return sb.toString(); + prompt.append(bounded(checklist.toString(), 4000)); } - String gap = result.gap(); - if (gap == null || gap.isBlank()) gap = "the goal is not yet complete."; - return "Continue working on the goal. Still missing: " + gap - + "\nTake the next concrete step."; + String gap = result != null ? result.gap() : null; + if (gap != null && !gap.isBlank()) { + prompt.append("\nLatest evaluation: ").append(bounded(gap, 1000)); + } + if (persistent) { + prompt.append("\nIf essential input or permission is still unavailable after checking existing state, ") + .append("call waitForGoalInput with the precise missing requirement and ask the user once. ") + .append("Do not use this boundary because of difficulty, elapsed time, incomplete work, or transient errors."); + } + prompt.append("\nInspect authoritative state and any existing async handles before repeating side effects. ") + .append("Poll or resume existing operations instead of starting duplicates. ") + .append("Verify completed work against evidence; do not treat a prior attempt as success. ") + .append("If a section above was truncated, retrieve the full goal/checklist before acting. ") + .append("Take the next concrete step on the remaining criteria without changing the original objective."); + return prompt.toString(); + } + + private static String bounded(String text, int limit) { + if (text == null) return ""; + return text.length() <= limit ? text : text.substring(0, limit - 14) + "… [truncated]"; } } diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalSegmentRunner.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalSegmentRunner.java new file mode 100644 index 00000000..bc50196a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalSegmentRunner.java @@ -0,0 +1,217 @@ +package vip.mate.goal.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import reactor.core.Disposable; +import vip.mate.agent.AgentService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.runtime.ConversationTurnGate; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.channel.web.AgentStreamAccumulator; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.exception.MateClawException; +import vip.mate.goal.model.GoalEntity; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.ConcurrentHashMap; + +/** Runs and persists one ordinary graph segment without an HTTP subscriber. */ +@Component +public class GoalSegmentRunner { + private final AgentService agents; + private final ConversationService conversations; + private final ApprovalWorkflowService approvals; + private final ChatStreamTracker streams; + private final ObjectMapper mapper; + private final ConversationTurnGate gate; + private final ConcurrentHashMap workers=new ConcurrentHashMap<>(); + private static final class Worker { + final String conversationId; + final Thread thread=Thread.currentThread(); + final AtomicBoolean cancelled=new AtomicBoolean(); + final AtomicReference handle=new AtomicReference<>(); + volatile boolean interactive; + Worker(String conversationId) { this.conversationId=conversationId; } + } + @org.springframework.beans.factory.annotation.Autowired + private GoalService goals; + + public GoalSegmentRunner(AgentService agents, ConversationService conversations, + ApprovalWorkflowService approvals, ChatStreamTracker streams, ObjectMapper mapper, ConversationTurnGate gate) { + this.agents=agents;this.conversations=conversations;this.approvals=approvals; + this.streams=streams;this.mapper=mapper;this.gate=gate; + } + + public record Result(String finishReason, boolean awaitingApproval, boolean evaluationUnavailable) { + public Result(String finishReason, boolean awaitingApproval) { this(finishReason,awaitingApproval,false); } + } + + /** Cancel this worker only, never a newer conversation generation or a user turn. */ + public void cancel(Long goalId) { + Worker worker=workers.get(goalId); + if (worker!=null && !worker.interactive) { + cancelWorker(worker); + } + } + + /** User Stop applies even after the goal completed and queued interactive work took over. */ + public void stopConversation(String conversationId) { + workers.values().stream().filter(w -> Objects.equals(w.conversationId,conversationId)).forEach(this::cancelWorker); + } + + private void cancelWorker(Worker worker) { + worker.cancelled.set(true); + streams.cancelRun(worker.handle.get()); + worker.thread.interrupt(); + } + + public Result run(GoalEntity goal, String prompt, boolean recovered) { + String convId=goal.getConversationId(); + var permit=gate.tryAcquire(convId); + if (permit==null) throw new MateClawException("err.agent.conversation_busy",409,"Conversation is busy"); + Worker worker=new Worker(convId); + try { + workers.put(goal.getId(),worker); + var conv=conversations.findByConversationId(convId); + if (conv==null || !Objects.equals(conv.getWorkspaceId(),goal.getWorkspaceId()) + || !Objects.equals(conv.getAgentId(),goal.getAgentId()) + || !Objects.equals(conv.getUsername(),goal.getCreatedBy()) + || Integer.valueOf(1).equals(conv.getDeleted()) || Integer.valueOf(1).equals(conv.getArchived())) { + throw new IllegalStateException("Goal conversation identity changed or conversation unavailable"); + } + var agent=agents.getAgent(goal.getAgentId()); + if (agent==null || Boolean.FALSE.equals(agent.getEnabled()) + || (agent.getRuntimeType()!=null && !"native".equals(agent.getRuntimeType()))) { + throw new IllegalStateException("Goal requires an enabled native runtime with goal evaluation"); + } + if (approvals.findPendingByConversation(convId)!=null) return new Result("",true); + if (streams.isRunning(convId)) { + throw new MateClawException("err.agent.conversation_busy",409,"Conversation has pending input"); + } + String guidance=recovered ? "The previous execution was interrupted by a runtime restart. " + + "Inspect the workspace, progress ledger and existing async handles before acting. " + + "Do not replay side effects whose outcome is unknown; request review if their outcome cannot be verified.\n" : ""; + ChatOrigin origin=ChatOrigin.web(convId,goal.getCreatedBy(),goal.getWorkspaceId(),null).withAgent(goal.getAgentId()); + Result result; + ChatStreamTracker.QueuedInput queued=streams.consumeQueuedInput(convId); + do { + String input=guidance+prompt; + if (queued!=null) { + worker.interactive=true; + if (!queued.persisted()) { + var saved=conversations.saveMessage(convId,"user",queued.message(),queued.contentParts(),"queued"); + if (saved!=null) origin=origin.withOriginMessageId(saved.getId()); + } + if (queued.agentId()!=null && !queued.agentId().equals(goal.getAgentId())) { + throw new IllegalStateException("Queued input targets a different agent; user review required"); + } + input=queuedPrompt(queued); + streams.broadcastObject(convId,"queued_input_started",Map.of("conversationId",convId,"message",input)); + } else if (goals!=null && goals.getById(goal.getId()).getStatus()!=vip.mate.goal.model.GoalStatus.ACTIVE) { + return new Result("stopped",false); + } + result=runSegment(goal,input,origin,permit,worker); + if (result.awaitingApproval() || "stopped".equals(result.finishReason())) return result; + queued=streams.consumeQueuedInput(convId); + } while (queued!=null); + return result; + } catch (RuntimeException error) { + // Accepted user input must survive even when this goal cannot continue. + ChatStreamTracker.QueuedInput pending; + while ((pending=streams.consumeQueuedInput(convId))!=null) { + if (!pending.persisted()) conversations.saveMessage(convId,"user",pending.message(),pending.contentParts(),"queued"); + } + streams.broadcastObject(convId,"warning",Map.of("message", + "Goal execution interrupted. Queued input was saved; review the execution state before resuming.")); + throw error; + } finally { + workers.remove(goal.getId(),worker); + permit.close(); + } + } + + private Result runSegment(GoalEntity goal, String input, ChatOrigin origin, ConversationTurnGate.Permit permit, Worker worker) { + String convId=goal.getConversationId(); + var handle=streams.register(convId); + worker.handle.set(handle); + streams.incrementFlux(convId); + AgentStreamAccumulator accumulator=new AgentStreamAccumulator(mapper,new AgentStreamAccumulator.Sink() { + @Override public void broadcast(String id,String name,Object payload) { streams.broadcastObject(id,name,payload); } + @Override public void updatePhase(String id,String phase) { streams.updatePhase(id,phase); } + }); + AtomicReference failure=new AtomicReference<>(); + AtomicBoolean evaluationUnavailable=new AtomicBoolean(); + AtomicBoolean persisted=new AtomicBoolean(); + CountDownLatch finished=new CountDownLatch(1); + Disposable subscription=null; + try { + if (worker.cancelled.get() || Thread.currentThread().isInterrupted()) throw new InterruptedException(); + conversations.updateStreamStatus(convId,"running"); + streams.broadcastObject(convId,"message_start",Map.of("role","assistant","trigger","goal")); + subscription=gate.withPermit(permit,() -> vip.mate.agent.context.GoalContinuationContext.call(() -> + reactor.core.publisher.Flux.defer(() -> { + if (worker.cancelled.get()) return reactor.core.publisher.Flux.empty(); + return agents.chatStructuredStream(goal.getAgentId(),input, + convId,goal.getCreatedBy(),null,origin) + .doOnNext(delta -> { + accumulator.accept(delta,convId); + if ("goal_evaluated".equals(delta.eventType()) && delta.eventData()!=null + && (Boolean.TRUE.equals(delta.eventData().get("skipped")) + || "fallback".equals(delta.eventData().get("decision")))) evaluationUnavailable.set(true); + }); + }) + .doOnSubscribe(s -> streams.setDisposable(handle, s::cancel)) + .doFinally(signal -> finished.countDown()) + .subscribe(delta -> {},failure::set))); + streams.setDisposable(handle,subscription); + finished.await(); + String reason=streams.isStopRequested(convId) + ? streams.getInterruptType(convId)==ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP + ? "interrupted" : "stopped" + : accumulator.getFinishReason(); + String status="stopped".equals(reason) ? "stopped" : "interrupted".equals(reason) ? "interrupted" : accumulator.isAwaitingApproval() + ? "awaiting_approval" : failure.get()!=null || "error_fallback".equals(reason) ? "error" : "completed"; + persist(convId,accumulator,status); + persisted.set(true); + streams.broadcastObject(convId,"message_complete",Map.of("status",status,"trigger","goal")); + if (failure.get()!=null && !"stopped".equals(reason)) { + throw failure.get() instanceof RuntimeException runtime ? runtime : new RuntimeException(failure.get()); + } + return new Result(reason,accumulator.isAwaitingApproval(),evaluationUnavailable.get()); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Goal worker interrupted; recover from persisted evidence",interrupted); + } finally { + if (worker.cancelled.get() || Thread.currentThread().isInterrupted()) streams.cancelRun(handle); + if (subscription!=null) subscription.dispose(); + try { + if (!persisted.get()) persist(convId,accumulator,"interrupted"); + } finally { + conversations.updateStreamStatus(convId,"idle"); + streams.broadcastObject(convId,"done",Map.of("status","segment_finished")); + streams.complete(handle); + } + } + } + + private void persist(String convId,AgentStreamAccumulator accumulator,String status) { + conversations.saveMessage(convId,"assistant",accumulator.getContent(),accumulator.toAssistantParts(),status, + accumulator.getPromptTokens(),accumulator.getCompletionTokens(),accumulator.getCacheReadTokens(), + accumulator.getCacheWriteTokens(),accumulator.getReasoningTokens(),accumulator.getRuntimeModelName(), + accumulator.getRuntimeProviderId(),accumulator.toMetadataJson()); + } + + private String queuedPrompt(ChatStreamTracker.QueuedInput queued) { + if (queued.contentParts()==null || queued.contentParts().isEmpty()) return queued.message(); + var message=new vip.mate.workspace.conversation.model.MessageEntity(); + message.setContent(queued.message()); + try { message.setContentParts(mapper.writeValueAsString(queued.contentParts())); } + catch (com.fasterxml.jackson.core.JsonProcessingException error) { throw new IllegalArgumentException("Invalid queued input",error); } + return conversations.renderMessageContent(message,true); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java index a90d6ea6..6c7e46bb 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java @@ -47,6 +47,9 @@ public interface GoalService { // ==================== State machine ==================== GoalEntity pause(Long id, String username); + + /** Pause a persistent active goal until essential user input or permission is provided. */ + GoalEntity waitForInput(Long id, String reason, String username); GoalEntity resume(Long id, String username); GoalEntity abandon(Long id, String username); diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java index 3d989d11..e9b570ce 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DuplicateKeyException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import vip.mate.audit.service.AuditEventService; @@ -54,6 +55,7 @@ public class GoalServiceImpl implements GoalService { private final GoalProperties properties; private final AuditEventService auditEventService; private final ObjectMapper objectMapper; + private ApplicationEventPublisher applicationEventPublisher; /** * Optional — only set when the memory subsystem is wired. On goal @@ -81,6 +83,11 @@ public class GoalServiceImpl implements GoalService { this.memoryManager = memoryManager; } + @Autowired(required = false) + public void setApplicationEventPublisher(ApplicationEventPublisher publisher) { + this.applicationEventPublisher = publisher; + } + // ==================== CRUD ==================== @Override @@ -108,11 +115,13 @@ public class GoalServiceImpl implements GoalService { entity.setExitCriteria(req.getExitCriteria()); entity.setSuccessCheckPrompt(req.getSuccessCheckPrompt()); entity.setStatus(GoalStatus.ACTIVE); + boolean persistent = persistentOnCreate(req); + entity.setPersistentExecution(persistent); entity.setTurnBudget(req.getTurnBudget() != null - ? req.getTurnBudget() : properties.getDefaultTurnBudget()); + ? req.getTurnBudget() : persistent ? 0 : properties.getDefaultTurnBudget()); entity.setTurnsUsed(0); entity.setLlmCallBudget(req.getLlmCallBudget() != null - ? req.getLlmCallBudget() : properties.getDefaultLlmCallBudget()); + ? req.getLlmCallBudget() : persistent ? 0 : properties.getDefaultLlmCallBudget()); entity.setAgentLlmCallsUsed(0); entity.setEvalLlmCallsUsed(0); // Three-state default: explicit true/false is honored; null falls @@ -143,6 +152,7 @@ public class GoalServiceImpl implements GoalService { writeEvent(entity.getId(), GoalEventType.CREATED, null, Map.of( "title", entity.getTitle(), + "persistentExecution", persistent, "turnBudget", entity.getTurnBudget(), "llmCallBudget", entity.getLlmCallBudget(), "by", username)); @@ -193,16 +203,22 @@ public class GoalServiceImpl implements GoalService { @Override @Transactional public GoalEntity update(Long id, GoalUpdateRequest req, String username) { - // Pre-validate constant fields once; the actual not-terminal check - // happens inside the builder against the fresh entity so a status - // flip between this method's entry and a CAS retry is honoured. - if (req.getTurnBudget() != null) validateBudget(req.getTurnBudget(), "turnBudget"); - if (req.getLlmCallBudget() != null) validateBudget(req.getLlmCallBudget(), "llmCallBudget"); - + // Validate mode and budgets together against each freshly read CAS row. GoalEntity updated = retryOptimistic(id, "update", fresh -> { ensureNotTerminal(fresh, "update"); + boolean persistent = req.getPersistentExecution() != null + ? req.getPersistentExecution() : Boolean.TRUE.equals(fresh.getPersistentExecution()); + Integer turns = req.getTurnBudget() != null ? req.getTurnBudget() : fresh.getTurnBudget(); + Integer calls = req.getLlmCallBudget() != null ? req.getLlmCallBudget() : fresh.getLlmCallBudget(); + if (turns != null && (req.getTurnBudget() != null || req.getPersistentExecution() != null)) + validateBudget(turns, "turnBudget", persistent); + if (calls != null && (req.getLlmCallBudget() != null || req.getPersistentExecution() != null)) + validateBudget(calls, "llmCallBudget", persistent); LambdaUpdateWrapper w = baseLockedUpdate(fresh); boolean changed = false; + if (req.getPersistentExecution() != null) { + w.set(GoalEntity::getPersistentExecution, req.getPersistentExecution()); changed = true; + } if (req.getTitle() != null && !req.getTitle().isBlank()) { w.set(GoalEntity::getTitle, req.getTitle().trim()); changed = true; } @@ -255,11 +271,42 @@ public class GoalServiceImpl implements GoalService { GoalEventType.PAUSED, "goal.paused", username); } + @Override + @Transactional + public GoalEntity waitForInput(Long id, String reason, String username) { + if (reason == null || reason.isBlank()) { + throw new MateClawException("err.goal.wait_reason_required", 400, + "A precise reason describing the missing input or permission is required"); + } + String trimmed = reason.trim(); + String boundedReason = trimmed.length() <= 1000 ? trimmed : trimmed.substring(0, 997) + "..."; + GoalEntity paused = retryOptimistic(id, "waitForInput", fresh -> { + if (fresh.getStatus() != GoalStatus.ACTIVE || !Boolean.TRUE.equals(fresh.getPersistentExecution())) { + throw new MateClawException("err.goal.wait_requires_active_persistent", 409, + "Waiting for input requires an active persistent goal"); + } + LambdaUpdateWrapper update = baseLockedUpdate(fresh) + .set(GoalEntity::getStatus, GoalStatus.PAUSED) + .set(GoalEntity::getProgressSummary, "Waiting for input: " + boundedReason); + bumpVersionAndTime(update); + return update; + }); + Map detail = Map.of("by", username, "reason", boundedReason, + "state", "waiting_input", "from", "active", "to", "paused"); + writeEvent(id, GoalEventType.PAUSED, null, detail); + recordAudit("goal.waiting_input", paused, detail); + return paused; + } + @Override @Transactional public GoalEntity resume(Long id, String username) { - return flipStatus(id, GoalStatus.PAUSED, GoalStatus.ACTIVE, + GoalEntity resumed = flipStatus(id, GoalStatus.PAUSED, GoalStatus.ACTIVE, GoalEventType.RESUMED, "goal.resumed", username); + if (Boolean.TRUE.equals(resumed.getPersistentExecution()) && applicationEventPublisher != null) { + applicationEventPublisher.publishEvent(new GoalExecutionSignal.Resume(id)); + } + return resumed; } @Override @@ -283,17 +330,23 @@ public class GoalServiceImpl implements GoalService { public GoalEntity markCompleted(Long id, GoalEvaluationResult result) { GoalEntity g = retryOptimistic(id, "markCompleted", fresh -> { if (fresh.getStatus().isTerminal()) return null; // idempotent + boolean persistent = Boolean.TRUE.equals(fresh.getPersistentExecution()); + List existing = GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper); + if (persistent && (fresh.getStatus() != GoalStatus.ACTIVE || existing.isEmpty() + || existing.stream().anyMatch(c -> c == null || !c.passed() + || c.evidence() == null || c.evidence().isBlank()))) { + throw new MateClawException("err.goal.completion_not_verified", 409, + "Persistent completion requires an active goal and evidence for every current criterion"); + } LambdaUpdateWrapper w = baseLockedUpdate(fresh) .set(GoalEntity::getStatus, GoalStatus.COMPLETED); if (result != null) { w.set(GoalEntity::getCompletionScore, result.score()) .set(GoalEntity::getProgressSummary, result.gap()); } - // Snapshot the checklist as fully satisfied. Idempotent for the - // auto path (recordEvaluation already merged all-passed); required - // for manual completion, which has no preceding verdict. - List existing = GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper); - if (!existing.isEmpty()) { + // Preserve verified persistent evidence verbatim. Legacy manual + // completion retains its historical force-passed checklist snapshot. + if (!persistent && !existing.isEmpty()) { List allPassed = existing.stream() .map(c -> c.passed() ? c : new GoalCriterion(c.id(), c.text(), true, c.evidence() == null || c.evidence().isBlank() @@ -337,8 +390,14 @@ public class GoalServiceImpl implements GoalService { public GoalEntity markExhausted(Long id, String reason) { GoalEntity g = retryOptimistic(id, "markExhausted", fresh -> { if (fresh.getStatus().isTerminal()) return null; + boolean persistent = Boolean.TRUE.equals(fresh.getPersistentExecution()); LambdaUpdateWrapper w = baseLockedUpdate(fresh) - .set(GoalEntity::getStatus, GoalStatus.EXHAUSTED); + .set(GoalEntity::getStatus, persistent ? GoalStatus.PAUSED : GoalStatus.EXHAUSTED); + if (persistent) { + w.set(GoalEntity::getProgressSummary, "Paused: " + + (reason != null ? reason : "budget limit") + + ". Increase the budget and resume to continue."); + } bumpVersionAndTime(w); return w; }); @@ -347,8 +406,9 @@ public class GoalServiceImpl implements GoalService { detail.put("turnsUsed", g.getTurnsUsed()); detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed()); detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed()); - writeEvent(id, GoalEventType.EXHAUSTED, null, detail); - recordAudit("goal.exhausted", g, detail); + boolean persistent = Boolean.TRUE.equals(g.getPersistentExecution()); + writeEvent(id, persistent ? GoalEventType.PAUSED : GoalEventType.EXHAUSTED, null, detail); + recordAudit(persistent ? "goal.paused" : "goal.exhausted", g, detail); return g; } @@ -375,7 +435,10 @@ public class GoalServiceImpl implements GoalService { .setSql("agent_llm_calls_used = agent_llm_calls_used + " + agentDelta) .setSql("eval_llm_calls_used = eval_llm_calls_used + " + evalDelta) .set(GoalEntity::getLastEvaluationAt, LocalDateTime.now()); - if (result != null) { + // Late model results still consume usage, but cannot overwrite a + // persistent pause/input boundary established while the call ran. + if (result != null && (!Boolean.TRUE.equals(fresh.getPersistentExecution()) + || fresh.getStatus() == GoalStatus.ACTIVE)) { w.set(GoalEntity::getCompletionScore, result.score()) .set(GoalEntity::getProgressSummary, result.gap()); // Persist the checklist by carrier: bootstrap writes the fresh @@ -431,16 +494,18 @@ public class GoalServiceImpl implements GoalService { public boolean isBudgetExhausted(GoalEntity goal) { int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0; int turnBudget = goal.getTurnBudget() != null ? goal.getTurnBudget() : Integer.MAX_VALUE; - if (turns >= turnBudget) return true; + boolean persistent = Boolean.TRUE.equals(goal.getPersistentExecution()); + if ((!persistent || turnBudget != 0) && turns >= turnBudget) return true; int callBudget = goal.getLlmCallBudget() != null ? goal.getLlmCallBudget() : Integer.MAX_VALUE; - return goal.totalLlmCallsUsed() >= callBudget; + return (!persistent || callBudget != 0) && goal.totalLlmCallsUsed() >= callBudget; } @Override public String exhaustionReason(GoalEntity goal) { int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0; int turnBudget = goal.getTurnBudget() != null ? goal.getTurnBudget() : Integer.MAX_VALUE; - if (turns >= turnBudget) return "turn_budget"; + if ((!Boolean.TRUE.equals(goal.getPersistentExecution()) || turnBudget != 0) + && turns >= turnBudget) return "turn_budget"; return "llm_call_budget"; } @@ -544,6 +609,7 @@ public class GoalServiceImpl implements GoalService { r.setExitCriteria(e.getExitCriteria()); r.setSuccessCheckPrompt(e.getSuccessCheckPrompt()); r.setStatus(e.getStatus()); + r.setPersistentExecution(Boolean.TRUE.equals(e.getPersistentExecution())); r.setTurnBudget(e.getTurnBudget()); r.setTurnsUsed(e.getTurnsUsed()); r.setLlmCallBudget(e.getLlmCallBudget()); @@ -591,14 +657,20 @@ public class GoalServiceImpl implements GoalService { if (req.getTitle().length() > 255) { throw new MateClawException("err.goal.bad_request", 400, "title too long (>255)"); } - if (req.getTurnBudget() != null) validateBudget(req.getTurnBudget(), "turnBudget"); - if (req.getLlmCallBudget() != null) validateBudget(req.getLlmCallBudget(), "llmCallBudget"); + boolean persistent = persistentOnCreate(req); + if (req.getTurnBudget() != null) validateBudget(req.getTurnBudget(), "turnBudget", persistent); + if (req.getLlmCallBudget() != null) validateBudget(req.getLlmCallBudget(), "llmCallBudget", persistent); } - private static void validateBudget(int v, String name) { - if (v <= 0) { + private boolean persistentOnCreate(GoalCreateRequest req) { + return req.getPersistentExecution() != null + ? req.getPersistentExecution() : properties.isDefaultPersistentExecution(); + } + + private static void validateBudget(int v, String name, boolean persistent) { + if (v < 0 || (!persistent && v == 0)) { throw new MateClawException("err.goal.invalid_budget", 400, - name + " must be > 0, got " + v); + name + (persistent ? " must be >= 0, got " : " must be > 0, got ") + v); } } @@ -680,6 +752,11 @@ public class GoalServiceImpl implements GoalService { throw new MateClawException("err.goal.bad_transition", 409, "Cannot transition " + fresh.getStatus().getValue() + " -> " + to.getValue()); } + if (to == GoalStatus.ACTIVE && Boolean.TRUE.equals(fresh.getPersistentExecution()) + && isBudgetExhausted(fresh)) { + throw new MateClawException("err.goal.budget_exhausted", 409, + "Increase the exhausted budget before resuming: " + exhaustionReason(fresh)); + } LambdaUpdateWrapper w = baseLockedUpdate(fresh) .set(GoalEntity::getStatus, to); bumpVersionAndTime(w); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java index e3b56a82..9136b428 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java @@ -134,6 +134,7 @@ public class DelegateAgentTool { "addGoalCriterion", "completeGoal", "getGoalStatus", + "waitForGoalInput", // Employee authoring spawns persistent agents; a delegated child // doing so risks recursive team creation and privilege creep, so // it stays with the parent (same stance as delegate* recursion diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java index 94195a1c..63f1784a 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java @@ -28,7 +28,7 @@ import java.util.Map; * an objective that spans multiple turns — the runtime then tracks * progress across the entire conversation. * - *

    All four tool names are added to + *

    All goal tool names are added to * {@code DelegateAgentTool.DEFAULT_CHILD_DENIED_TOOLS} so a child agent * cannot mutate the parent conversation's goal. Goal ownership is bound * to the parent conversation, period. @@ -56,7 +56,7 @@ public class GoalManagementTool { required = false) String description, @ToolParam(description = "Exit criteria the evaluator scores against (e.g. 'tests pass + deployed').", required = false) String exitCriteria, - @ToolParam(description = "Max evaluation turns before exhaustion. Default 20.", + @ToolParam(description = "Optional evaluation-turn cap. Persistent goals default to unlimited (0); positive values pause execution at the cap. Legacy goals default to 20.", required = false) Integer turnBudget, @ToolParam(description = "If true, the agent may auto-followup when progress is incomplete. " + "Omit to use the system default.", @@ -211,6 +211,44 @@ public class GoalManagementTool { return successJson(out); } + @Tool(description = """ + Pause the current persistent goal only when essential user input or permission \ + is missing after inspecting available state, files, and existing async handles. \ + Give the precise missing information or authorization and what was checked; \ + ask the user once, then wait for explicit resume. Do not use this because of \ + difficulty, elapsed time, incomplete work, or a transient error. This pauses \ + the goal without marking it complete.""") + public String waitForGoalInput( + @ToolParam(description = "Precise essential input or permission missing, why it is necessary, " + + "and which available state was checked first.") String reason, + @Nullable ToolContext ctx) { + if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled"); + if (reason == null || reason.isBlank()) { + return errorJson("A precise reason describing the missing input or permission is required"); + } + ChatOrigin origin = ChatOrigin.from(ctx); + if (origin == null || origin.conversationId() == null || origin.conversationId().isBlank()) { + return errorJson("waitForGoalInput requires a bound conversation context"); + } + GoalEntity goal = resolveActive(ctx); + if (goal == null || goal.getStatus() != GoalStatus.ACTIVE + || !origin.conversationId().equals(goal.getConversationId())) { + return errorJson("No active goal on this conversation"); + } + if (!Boolean.TRUE.equals(goal.getPersistentExecution())) { + return errorJson("Waiting for input requires a persistent goal"); + } + try { + GoalEntity paused = goalService.waitForInput(goal.getId(), reason.trim(), resolveUsername(ctx)); + broadcastGoalEvent(paused.getConversationId(), "goal_updated", paused); + return successJson(Map.of("goalId", String.valueOf(paused.getId()), + "status", paused.getStatus().getValue(), "waitingForInput", true, + "reason", paused.getProgressSummary() == null ? "" : paused.getProgressSummary())); + } catch (MateClawException error) { + return errorJson(error.getMessage()); + } + } + // ==================== Internals ==================== private GoalEntity resolveActive(ToolContext ctx) { diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V188__goal_continuation.sql b/mateclaw-server/src/main/resources/db/migration/h2/V188__goal_continuation.sql new file mode 100644 index 00000000..baa088e9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V188__goal_continuation.sql @@ -0,0 +1,14 @@ +-- Existing goals retain legacy behavior; new goals opt in through GoalService. +ALTER TABLE mate_agent_goal ADD COLUMN persistent_execution BOOLEAN NOT NULL DEFAULT FALSE; +CREATE TABLE mate_goal_continuation ( + goal_id BIGINT PRIMARY KEY, + state VARCHAR(32) NOT NULL, + next_run_at TIMESTAMP NOT NULL, + lease_owner VARCHAR(64), + lease_until TIMESTAMP, + failures INT NOT NULL DEFAULT 0, + wake_requested BOOLEAN NOT NULL DEFAULT FALSE, + reason VARCHAR(1000) NOT NULL DEFAULT '', + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX idx_goal_continuation_due ON mate_goal_continuation(state,next_run_at); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V188__goal_continuation.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V188__goal_continuation.sql new file mode 100644 index 00000000..baa088e9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V188__goal_continuation.sql @@ -0,0 +1,14 @@ +-- Existing goals retain legacy behavior; new goals opt in through GoalService. +ALTER TABLE mate_agent_goal ADD COLUMN persistent_execution BOOLEAN NOT NULL DEFAULT FALSE; +CREATE TABLE mate_goal_continuation ( + goal_id BIGINT PRIMARY KEY, + state VARCHAR(32) NOT NULL, + next_run_at TIMESTAMP NOT NULL, + lease_owner VARCHAR(64), + lease_until TIMESTAMP, + failures INT NOT NULL DEFAULT 0, + wake_requested BOOLEAN NOT NULL DEFAULT FALSE, + reason VARCHAR(1000) NOT NULL DEFAULT '', + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX idx_goal_continuation_due ON mate_goal_continuation(state,next_run_at); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V188__goal_continuation.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V188__goal_continuation.sql new file mode 100644 index 00000000..0fb0842e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V188__goal_continuation.sql @@ -0,0 +1,14 @@ +-- Existing goals retain legacy behavior; new goals opt in through GoalService. +ALTER TABLE mate_agent_goal ADD COLUMN persistent_execution BOOLEAN NOT NULL DEFAULT FALSE; +CREATE TABLE mate_goal_continuation ( + goal_id BIGINT PRIMARY KEY, + state VARCHAR(32) NOT NULL, + next_run_at DATETIME(6) NOT NULL, + lease_owner VARCHAR(64), + lease_until DATETIME(6), + failures INT NOT NULL DEFAULT 0, + wake_requested BOOLEAN NOT NULL DEFAULT FALSE, + reason VARCHAR(1000) NOT NULL DEFAULT '', + updated_at DATETIME(6) NOT NULL +); +CREATE INDEX idx_goal_continuation_due ON mate_goal_continuation(state,next_run_at); diff --git a/mateclaw-server/src/main/resources/docs/en/goals.md b/mateclaw-server/src/main/resources/docs/en/goals.md index 56af4986..23b9f45b 100644 --- a/mateclaw-server/src/main/resources/docs/en/goals.md +++ b/mateclaw-server/src/main/resources/docs/en/goals.md @@ -9,6 +9,14 @@ head: # Persistent Goals +## Continuous execution (v1) + +New goals default to `persistentExecution=true`; omitted `turnBudget` and `llmCallBudget` become `0` (no cumulative limit). Explicit positive budgets still apply. Existing goals retain legacy mode and do not start automatically after an upgrade. + +A durable queue and background supervisor schedule persistent goals across bounded graph segments. A graph limit ends a segment, not the goal. Queued work, cooldowns, retries and expired leases survive server restarts. Completion requires all persisted criteria to pass with nonblank evidence. Stop, essential missing input, approval denial and budget exhaustion pause execution until an explicit resume. + +`GET /api/v1/goals/{id}/execution` exposes the latest scheduling state, reason and due time; the goal API remains authoritative for current goal status. Streams broadcast scheduling changes through `goal_continuation`. V1 supports a single backend instance and the native runtime. External tool effects are not guaranteed exactly once; recovery must check existing artifacts and async handles. Budgets are checked at segment boundaries, not as per-request spending caps. + > **You used to repeat the context every turn. Now you set a goal once, the worker follows.** You say "deploy this blog to fly.io" in one turn, the worker answers, and stops. Next turn you have to remember to ask "is DNS set? cert signed? tests run?" — you're keeping the goal in your head, not the worker. @@ -109,6 +117,8 @@ After every turn, a backend evaluator node runs: ### Auto-followup +Persistent mode schedules a fresh graph segment through the durable supervisor. The graph-local injection below applies only to legacy goals (`persistentExecution=false`). + When `autoFollowupEnabled=true` and this turn's evaluator decision is "continue", the backend: 1. Writes a `followup_injected` event to the timeline @@ -198,9 +208,9 @@ The evaluation logic implements Spring AI's `Evaluator` interface: it does goal- --- -## Four built-in tools (worker-callable) +## Built-in goal tools (worker-callable) -These four ship as agent-wide system tools — no binding setup needed: +These tools ship as agent-wide system tools — no binding setup needed: | Tool | Purpose | Prompt example | |---|---|---| @@ -208,6 +218,7 @@ These four ship as agent-wide system tools — no binding setup needed: | **addGoalCriterion** | Append a sub-criterion to the active goal | "Add: must support IPv6" | | **completeGoal** | Explicitly mark done | "All items done — call completeGoal" | | **getGoalStatus** | Inspect current state | "How are we doing?" | +| **waitForGoalInput** | Pause a persistent goal for essential missing input | "Wait for the user to supply the deployment domain" | On completion (`completeGoal`, or the evaluator judging **every criterion passed**), the worker forwards a summary to its [long-term memory](./memory) so future conversations can recall it. @@ -215,7 +226,7 @@ On completion (`completeGoal`, or the evaluator judging **every criterion passed ## Sub-agents cannot mutate the parent's goal -In [multi-agent collaboration](./agents) a parent worker can delegate to a child worker. Children **don't see** the four goal tools — the goal is the parent conversation's state, the child is a stateless executor. +In [multi-agent collaboration](./agents) a parent worker can delegate to a child worker. Children **don't see** the goal tools — the goal is the parent conversation's state, the child is a stateless executor. > This is intentional. Children do work for the parent, but the goal stays owned by the parent. @@ -227,7 +238,7 @@ In [multi-agent collaboration](./agents) a parent worker can delegate to a child turnsUsed >= turnBudget OR (agentLlmCallsUsed + evalLlmCallsUsed) >= llmCallBudget ``` -Either one hit → goal status flips to **exhausted**, no more evaluations, no more follow-ups, ring turns red-orange. The last turn's assistant reply still goes through. +Persistent mode checks positive budgets only; `0` means unlimited. Reaching a budget pauses the goal with scheduling state **budget_limited**; increase the budget and resume. Legacy goals still enter terminal **exhausted** and require a new goal to continue. The current segment's reply is still saved. Your options: @@ -247,7 +258,8 @@ Your options: active ──all criteria passed / completeGoal──→ completed (terminal) ↓ - active ──turns_used / llm_calls exhausted ────→ exhausted (terminal) + active ──positive budget reached (persistent) → paused + active ──budget reached (legacy) ────────────→ exhausted (terminal) ↓ active ──user abandon ────────────────────────→ abandoned (terminal) ``` @@ -300,9 +312,12 @@ mateclaw: default-auto-followup: true # Runtime master switch; when off, no goal injects a followup regardless of its per-goal flag. allow-auto-followup: true - # Default turn budget when the user doesn't override. + # New goals run persistently; omitted budgets mean unlimited (0). + default-persistent-execution: true + supervisor-poll-ms: 5000 + # Legacy default turn budget. default-turn-budget: 20 - # Default combined (agent + evaluator) LLM call budget. + # Legacy combined (agent + evaluator) LLM call budget. default-llm-call-budget: 200 # Minimum seconds between two consecutive auto-followups. auto-followup-cooldown-seconds: 0 @@ -330,7 +345,7 @@ Two tables, all `mate_`-prefixed: | `mate_agent_goal` | Goal itself; status / budgets / dual LLM counters / auto-followup config | | `mate_agent_goal_event` | Append-only event log; powers the timeline view | -Flyway migration `V120__agent_goal.sql` (H2 / MySQL / KingbaseES dialects). +`mate_goal_continuation` stores durable scheduling, due times and leases. Flyway migrations `V120__agent_goal.sql` and `V188__goal_continuation.sql` (H2 / MySQL / KingbaseES dialects). --- diff --git a/mateclaw-server/src/main/resources/docs/zh/goals.md b/mateclaw-server/src/main/resources/docs/zh/goals.md index 858342f8..dafcf614 100644 --- a/mateclaw-server/src/main/resources/docs/zh/goals.md +++ b/mateclaw-server/src/main/resources/docs/zh/goals.md @@ -9,6 +9,14 @@ head: # 持久化目标 +## 持续执行模式(第一版) + +新建目标默认 `persistentExecution=true`,省略预算时 `turnBudget=0`、`llmCallBudget=0` 表示不设累计上限。显式正预算仍生效;已有目标保留旧模式,不会在升级后自动启动。 + +持续目标由数据库队列和后台 supervisor 跨图片段调度;单次图执行的次数上限只结束当前片段,不结束目标。队列、冷却、重试和过期租约可在服务重启后恢复。仅当清单全部通过且有非空证据时才能完成,Stop、缺少必要输入、审批拒绝和预算耗尽会暂停,需明确 resume。 + +`GET /api/v1/goals/{id}/execution` 返回独立的调度状态、原因和到期时间;它是最近的调度记录,目标当前状态以 goal API 为准。流中通过 `goal_continuation` 广播调度变化。第一版支持单后端实例的原生 runtime,不保证外部工具副作用恰好一次;恢复先检查已有产物与异步句柄。预算在片段边界检查,不是逐请求的硬费用限制。 + > **以前你每轮都要把上下文重复一遍。现在你定一个目标,员工自己跟。** 一次对话里你说"帮我把这个博客部署到 fly.io",员工答完一轮就停了。下一轮你要再问"DNS 配好没?证书呢?测试跑了吗?"——你在替它记目标。 @@ -109,7 +117,7 @@ POST /api/v1/goals ### 自动延续是怎么发生的 -如果 `autoFollowupEnabled=true` 且这一轮 evaluator 判 "continue",后台会: +持续模式会持久化下一次执行时间,由 supervisor 发起新的图片段。下面的图内延续流程只适用于 `persistentExecution=false` 的旧模式: 1. 写一条 `followup_injected` 事件到时间线 2. 给对话末尾 APPEND 一条用户消息。**1.5.0 起,如果目标有清单,这条消息会明确列出还没通过的那几条准则**——"5/8 已完成,剩余:① …… ② ……,去做剩下的";没有清单时回退到笼统的 "Continue working on the goal. Still missing: {gap}." @@ -198,9 +206,9 @@ Plan-Execute 模式下,单个步骤可能**抛异常**,也可能陷入**停 --- -## 4 个内置工具(员工可用) +## 内置目标工具(员工可用) -员工的工具集里默认包含这 4 个(无需手动绑定,是 agent-wide 系统级工具): +员工的工具集里默认包含以下工具(无需手动绑定,是 agent-wide 系统级工具): | 工具 | 用途 | 触发提示词示例 | |---|---|---| @@ -208,6 +216,7 @@ Plan-Execute 模式下,单个步骤可能**抛异常**,也可能陷入**停 | **addGoalCriterion** | 追加子准则到已有目标 | "再加一条准则:必须支持 IPv6" | | **completeGoal** | 显式标记完成 | "所有事项已做完,请 completeGoal" | | **getGoalStatus** | 查询当前 goal 状态 | "我们现在进展到哪了?" | +| **waitForGoalInput** | 持续目标缺少必要输入时暂停并记录原因 | "缺少部署域名,请等待用户补充" | 完成时(`completeGoal`,或 evaluator 判定**每一条准则都通过**),员工会把这个目标的总结同步到[长期记忆](./memory),后续对话能查得回来。 @@ -215,7 +224,7 @@ Plan-Execute 模式下,单个步骤可能**抛异常**,也可能陷入**停 ## 子员工不能改父员工的目标 -[多员工协作](./agents)里 parent 员工可以委派 child 员工干活。Child **看不到**这 4 个 goal 工具 — 目标是 parent 会话的状态,child 是无状态的执行体。 +[多员工协作](./agents)里 parent 员工可以委派 child 员工干活。Child **看不到**这些 goal 工具 — 目标是 parent 会话的状态,child 是无状态的执行体。 > 这一条是设计意图,不是 bug。child 帮 parent 做事,但目标的"所有权"留在 parent 那。 @@ -227,7 +236,7 @@ Plan-Execute 模式下,单个步骤可能**抛异常**,也可能陷入**停 turnsUsed >= turnBudget 或 (agentLlmCallsUsed + evalLlmCallsUsed) >= llmCallBudget ``` -任一条命中 → 目标状态翻为 **exhausted**,不再触发评估、不再注入 follow-up,光环变橙红色。员工的最后一轮回答会正常发送给你。 +持续模式仅检查正预算;`0` 表示不限。达到预算后目标进入 **paused**,调度状态为 **budget_limited**,增加预算后可 resume。旧模式仍进入终态 **exhausted**,需要新建目标才能继续。当前片段的回答仍会保存。 你的选择: @@ -247,7 +256,8 @@ turnsUsed >= turnBudget 或 (agentLlmCallsUsed + evalLlmCallsUsed) >= llmCallB active ──evaluator 全部准则通过 / completeGoal──→ completed (终态) ↓ - active ──turns_used/llm_calls 用完 ─────────→ exhausted (终态) + active ──正预算用完(持续模式)───────────→ paused + active ──预算用完(旧模式)───────────────→ exhausted (终态) ↓ active ──user abandon ─────────────────────→ abandoned (终态) ``` @@ -300,9 +310,12 @@ mateclaw: default-auto-followup: true # 运行期总开关;关掉则无论 per-goal 标志如何,都不注入自动延续 allow-auto-followup: true - # 默认 turn 预算 + # 新目标默认持续模式;省略预算表示不限(0) + default-persistent-execution: true + supervisor-poll-ms: 5000 + # 旧模式的默认 turn 预算 default-turn-budget: 20 - # 默认 LLM 调用预算(agent + evaluator 之和) + # 旧模式的默认 LLM 调用预算(agent + evaluator 之和) default-llm-call-budget: 200 # 自动延续之间至少隔多久(秒) auto-followup-cooldown-seconds: 0 @@ -322,14 +335,14 @@ mateclaw: ## 数据库 -两张表,都用 `mate_` 前缀: +相关表使用 `mate_` 前缀: | 表 | 用途 | |---|---| | `mate_agent_goal` | 目标本体;含 status / budget / 双 LLM 计数器 / 自动延续配置 | | `mate_agent_goal_event` | 目标的事件追加日志,drawer 时间线读它 | -迁移由 Flyway 跑 `V120__agent_goal.sql`(H2 / MySQL / KingbaseES 三方言)。 +持续调度表 `mate_goal_continuation` 记录队列、到期时间和租约。迁移由 Flyway 跑 `V120__agent_goal.sql` 和 `V188__goal_continuation.sql`(H2 / MySQL / KingbaseES 三方言)。 --- diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceTurnAdmissionTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceTurnAdmissionTest.java new file mode 100644 index 00000000..07344975 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceTurnAdmissionTest.java @@ -0,0 +1,51 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Flux; +import vip.mate.memory.MemoryProperties; +import java.util.function.BiFunction; +import java.util.function.Function; +import static org.junit.jupiter.api.Assertions.*; + +class AgentServiceTurnAdmissionTest { + @Test void onlyInteractiveCompletionWakesApprovalWaitEvenAfterContextHasExited() { + MemoryProperties properties = new MemoryProperties(); + properties.setLifecycleMediatorEnabled(false); + AgentService service = new AgentService(null,null,null,null,properties,null,null); + var events = org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class); + ReflectionTestUtils.setField(service,"events",events); + var source = reactor.core.publisher.Sinks.empty(); + BiFunction> invoke = (message,conversation) -> source.asMono().flux(); + Function content = Function.identity(); + Flux automatic = ReflectionTestUtils.invokeMethod(service,"withLifecycleFlux",1L,"a","conv",invoke,content); + vip.mate.agent.context.GoalContinuationContext.call(automatic::subscribe); + source.tryEmitEmpty(); + org.mockito.Mockito.verifyNoInteractions(events); + + BiFunction> interactiveInvoke = (message,conversation) -> Flux.empty(); + Flux interactive = ReflectionTestUtils.invokeMethod(service,"withLifecycleFlux",1L,"b","conv",interactiveInvoke,content); + interactive.blockLast(); + org.mockito.Mockito.verify(events).publishEvent(new vip.mate.goal.service.GoalExecutionSignal.TurnFinished("conv")); + } + + @Test void admissionIsLazySharedAndReleasedOnCancellation() { + MemoryProperties properties = new MemoryProperties(); + properties.setLifecycleMediatorEnabled(false); + AgentService service = new AgentService(null,null,null,null,properties,null,null); + BiFunction> invoke = (message,conversation) -> Flux.never(); + Function content = Function.identity(); + Flux first = ReflectionTestUtils.invokeMethod(service,"withLifecycleFlux",1L,"a","conv",invoke,content); + Flux second = ReflectionTestUtils.invokeMethod(service,"withLifecycleFlux",1L,"b","conv",invoke,content); + assertNotNull(first); assertNotNull(second); + var subscription = first.subscribe(); + var error = new java.util.concurrent.atomic.AtomicReference(); + second.subscribe(value -> {},error::set); + assertNotNull(error.get(),"a second turn must not execute alongside the first"); + subscription.dispose(); + error.set(null); + var third = second.subscribe(value -> {},error::set); + assertNull(error.get()); + third.dispose(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java index 91d4bd87..8cf1afa5 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java @@ -176,6 +176,18 @@ class GoalEvaluationNodeContinuationTest { assertFalse(out.containsKey(MateClawStateKeys.CURRENT_ITERATION)); } + @Test + void persistentGoalYieldsToDurableSupervisorInsteadOfSpendingGraphFollowups() throws Exception { + Fixture f = new Fixture(); + GoalEntity persistent = f.goalService.getById(1L); + persistent.setPersistentExecution(true); + persistent.setStatus(vip.mate.goal.model.GoalStatus.ACTIVE); + Map out = f.node().apply(f.state(FinishReason.NORMAL.getValue(),0,0)); + assertEquals(Boolean.TRUE,out.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN)); + assertFalse(out.containsKey(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED)); + verify(f.followupService,never()).maybeBuildFollowup(any(),any()); + } + // ===== Test fixture ===== private static final class Fixture { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/ConversationTurnGateTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/ConversationTurnGateTest.java new file mode 100644 index 00000000..59c1e72b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/ConversationTurnGateTest.java @@ -0,0 +1,35 @@ +package vip.mate.agent.runtime; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ConversationTurnGateTest { + @Test void onlyOneTurnOwnsConversationAndOldReleaseCannotReleaseNewOwner() { + ConversationTurnGate gate = new ConversationTurnGate(); + var first = gate.tryAcquire("conversation"); + assertNotNull(first); + assertNull(gate.tryAcquire("conversation")); + assertNotNull(gate.tryAcquire("other")); + first.close(); + var second = gate.tryAcquire("conversation"); + assertNotNull(second); + first.close(); + assertNull(gate.tryAcquire("conversation")); + second.close(); + assertNotNull(gate.tryAcquire("conversation")); + } + + @Test void admittedBackgroundCallCanEnterLifecycleWithoutReleasingOuterPermit() { + ConversationTurnGate gate = new ConversationTurnGate(); + var outer = gate.tryAcquire("conv"); + gate.withPermit(outer, () -> { + var nested = gate.tryAcquire("conv"); + assertNotNull(nested); + nested.close(); + return null; + }); + assertNull(gate.tryAcquire("conv")); + outer.close(); + assertNotNull(gate.tryAcquire("conv")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerWorkerReadOnlyTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerWorkerReadOnlyTest.java index 15d61aed..0e648e77 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerWorkerReadOnlyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerWorkerReadOnlyTest.java @@ -7,7 +7,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.core.Authentication; +import org.springframework.test.util.ReflectionTestUtils; import vip.mate.agent.AgentService; +import vip.mate.agent.runtime.ConversationTurnGate; import vip.mate.approval.ApprovalWorkflowService; import vip.mate.memory.identity.MemoryOwnerResolver; import vip.mate.memory.event.ConversationCompletionPublisher; @@ -19,6 +21,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; @ExtendWith(MockitoExtension.class) class ChatControllerWorkerReadOnlyTest { @@ -35,12 +39,14 @@ class ChatControllerWorkerReadOnlyTest { @Mock private Authentication authentication; private ChatController controller; + private final ConversationTurnGate gate = new ConversationTurnGate(); @BeforeEach void setUp() { controller = new ChatController(agentService, conversationService, approvalService, streamTracker, objectMapper, completionPublisher, memoryOwnerResolver, uploadLocationResolver, officePreviewService); + ReflectionTestUtils.setField(controller, "turnGate", gate); } @Test @@ -72,4 +78,150 @@ class ChatControllerWorkerReadOnlyTest { verify(streamTracker, never()).register(any()); verify(agentService, never()).chatStructuredStream(any(), any(), any(), any(), any(), any()); } + + @Test + void rejectsApprovalWhileAnotherProducerOwnsTheStream() { + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("busy-conversation"); + request.setMessage("/approve"); + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isUserMessageAllowed("busy-conversation")).thenReturn(true); + org.mockito.Mockito.lenient().when(streamTracker.isRunning("busy-conversation")).thenReturn(true); + + controller.chatStream(request, 1L, authentication); + + verify(approvalService, never()).findPendingByConversation(any()); + verify(approvalService, never()).resolveAndConsume(any(), any()); + verify(streamTracker, never()).register(any()); + verify(conversationService, never()).removeApprovalPlaceholders(any()); + } + + @Test + void rejectsForeignConversationBeforeInspectingApproval() { + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("foreign-conversation"); + request.setMessage("/approve"); + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isUserMessageAllowed("foreign-conversation")).thenReturn(true); + org.mockito.Mockito.lenient().when(conversationService.conversationExists("foreign-conversation")).thenReturn(true); + + controller.chatStream(request, 1L, authentication); + + verify(approvalService, never()).findPendingByConversation(any()); + verify(streamTracker, never()).register(any()); + } + + @Test + void synchronousChatRejectsBusyStreamBeforePersistingInput() { + ChatController.ChatRequest request = new ChatController.ChatRequest(); + request.setConversationId("busy-conversation"); + request.setMessage("new request"); + when(authentication.getName()).thenReturn("alice"); + org.mockito.Mockito.lenient().when(streamTracker.isRunning("busy-conversation")).thenReturn(true); + org.mockito.Mockito.lenient().when(agentService.chatWithUsage(any(), any(), any(), any())) + .thenReturn(new AgentService.ChatResult("reply", 0, 0, "model", "provider")); + + controller.chat(1L, request, 1L, authentication); + + verify(conversationService, never()).getOrCreateConversation(any(), any(), any(), any()); + verify(conversationService, never()).saveMessage(any(), any(), any(), org.mockito.ArgumentMatchers.anyList()); + verify(agentService, never()).chatWithUsage(any(), any(), any(), any()); + } + + @Test + void autonomousReservationRejectsApprovalAndRegenerationBeforeMutation() { + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isUserMessageAllowed("auto-conversation")).thenReturn(true); + try (var autonomous = gate.tryAcquire("auto-conversation")) { + assertNotNull(autonomous); + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("auto-conversation"); + request.setMessage("/approve"); + controller.chatStream(request, 1L, authentication); + request.setMessage("continue"); + request.setRegenerate(true); + controller.chatStream(request, 1L, authentication); + + assertNull(gate.tryAcquire("auto-conversation"), "rejected requests must not release another owner"); + } + + verify(approvalService, never()).findPendingByConversation(any()); + verify(approvalService, never()).resolveAndConsume(any(), any()); + verify(conversationService, never()).prepareRegenerate(any()); + verify(streamTracker, never()).register(any()); + verify(agentService, never()).chatStructuredStream(any(), any(), any(), any(), any(), any()); + } + + @Test + void missingApprovalReleasesSetupReservation() { + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isUserMessageAllowed("idle-conversation")).thenReturn(true); + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("idle-conversation"); + request.setMessage("/approve"); + + controller.chatStream(request, 1L, authentication); + + verify(approvalService).findPendingByConversation("idle-conversation"); + try (var next = gate.tryAcquire("idle-conversation")) { + assertNotNull(next); + } + } + + @Test + void idleApprovalIsConsumedWhileHoldingSetupReservation() { + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isUserMessageAllowed("idle-conversation")).thenReturn(true); + var pending = org.mockito.Mockito.mock(vip.mate.approval.PendingApproval.class); + when(pending.getPendingId()).thenReturn("pending-1"); + when(approvalService.findPendingByConversation("idle-conversation")).thenReturn(pending); + when(approvalService.resolveAndConsume("pending-1", "alice")).thenAnswer(invocation -> { + assertNull(gate.tryAcquire("idle-conversation"), "approval consumption must reserve ingress"); + return vip.mate.approval.ResolveOutcome.alreadyResolved("pending-1"); + }); + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("idle-conversation"); + request.setMessage("/approve"); + + controller.chatStream(request, 1L, authentication); + + verify(approvalService).resolveAndConsume("pending-1", "alice"); + try (var next = gate.tryAcquire("idle-conversation")) { + assertNotNull(next); + } + } + + @Test + void reconnectCanAttachWhileAutonomousTurnOwnsReservation() { + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isConversationOwner("auto-conversation", "alice")).thenReturn(true); + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("auto-conversation"); + request.setReconnect(true); + try (var autonomous = gate.tryAcquire("auto-conversation")) { + assertNotNull(autonomous); + controller.chatStream(request, 1L, authentication); + assertNull(gate.tryAcquire("auto-conversation")); + } + + verify(streamTracker).attach(org.mockito.ArgumentMatchers.eq("auto-conversation"), any(), + org.mockito.ArgumentMatchers.eq(0L)); + verify(streamTracker, never()).register(any()); + } + + @Test + void synchronousChatRejectsAutonomousReservationBeforeMutation() { + when(authentication.getName()).thenReturn("alice"); + ChatController.ChatRequest request = new ChatController.ChatRequest(); + request.setConversationId("auto-conversation"); + request.setMessage("new request"); + try (var autonomous = gate.tryAcquire("auto-conversation")) { + assertNotNull(autonomous); + controller.chat(1L, request, 1L, authentication); + assertNull(gate.tryAcquire("auto-conversation")); + } + + verify(conversationService, never()).getOrCreateConversation(any(), any(), any(), any()); + verify(agentService, never()).chatWithUsage(any(), any(), any(), any()); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerDetachSemanticsTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerDetachSemanticsTest.java index 56a7e71c..50c77ece 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerDetachSemanticsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerDetachSemanticsTest.java @@ -34,6 +34,15 @@ import static org.junit.jupiter.api.Assertions.assertTrue; */ class ChatStreamTrackerDetachSemanticsTest { + @Test + void stopPublishesDurableGoalControlEvenBetweenSegments() { + ChatStreamTracker tracker = newTracker(); + var context = org.mockito.Mockito.mock(org.springframework.context.ApplicationContext.class); + org.springframework.test.util.ReflectionTestUtils.setField(tracker,"applicationContext",context); + tracker.requestStop("idle-goal"); + org.mockito.Mockito.verify(context).publishEvent(new vip.mate.goal.service.GoalExecutionSignal.Stop("idle-goal")); + } + private ChatStreamTracker newTracker() { return new ChatStreamTracker(new ObjectMapper()); } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerEventIdTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerEventIdTest.java index 548530df..3f161abc 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerEventIdTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerEventIdTest.java @@ -18,6 +18,19 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class ChatStreamTrackerEventIdTest { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void continuationStateAfterSegmentCompletionIsAvailableOnReconnect(boolean fenced) { + ChatStreamTracker tracker = new ChatStreamTracker(new ObjectMapper()); + var handle = tracker.register("goal-channel"); + tracker.complete(handle); + if (fenced) tracker.broadcast(handle, "goal_continuation", "{\"state\":\"queued\"}"); + else tracker.broadcastObject("goal-channel", "goal_continuation", java.util.Map.of("state", "queued")); + CapturingEmitter reconnected = new CapturingEmitter(); + tracker.attach("goal-channel", reconnected); + assertTrue(reconnected.names.contains("goal_continuation")); + } + @Test void eventIdsIncreaseAcrossChannelsAndRecreatedRunState() { ChatStreamTracker tracker = new ChatStreamTracker(new ObjectMapper()); @@ -107,11 +120,16 @@ class ChatStreamTrackerEventIdTest { private static final class CapturingEmitter extends SseEmitter { private final List ids = new ArrayList<>(); + private final List names = new ArrayList<>(); @Override public void send(SseEventBuilder builder) throws IOException { Set entries = builder.build(); for (ResponseBodyEmitter.DataWithMediaType entry : entries) { + if (entry.getData() instanceof String text) { + text.lines().filter(line -> line.startsWith("event:")) + .forEach(line -> names.add(line.substring(6).trim())); + } if (entry.getData() instanceof String text && text.startsWith("id:")) { int end = text.indexOf('\n'); ids.add(Long.parseLong(text.substring(3, end).trim())); diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java index 21f96eb5..3d4e722c 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java @@ -4,15 +4,23 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DuplicateKeyException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; import vip.mate.MateClawApplication; +import vip.mate.approval.event.ApprovalResolutionEvent; import vip.mate.exception.MateClawException; import vip.mate.goal.model.GoalCreateRequest; import vip.mate.goal.model.GoalEntity; import vip.mate.goal.model.GoalStatus; import vip.mate.goal.service.GoalService; +import vip.mate.goal.service.GoalContinuationStore; import java.sql.Timestamp; import java.time.LocalDateTime; @@ -23,7 +31,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.fail; /** - * Integration test that pins two load-bearing DB invariants: + * Integration tests for goal persistence and transaction boundaries: * *

      *
    1. {@code GoalStatus} persists as lowercase strings ({@code "active"} @@ -33,6 +41,8 @@ import static org.junit.jupiter.api.Assertions.fail; *
    2. The {@code uk_agent_goal_active_conv} unique index rejects a * second active-row insert for the same conversation. Service-layer * pre-check is a UX nicety; this is the source of truth.
    3. + *
    4. Resume and approval decisions commit both goal and continuation state + * before independent transaction observers are allowed to continue.
    5. *
    * *

    Uses an in-memory H2 MySQL-compat database so Flyway runs V120 @@ -53,6 +63,9 @@ class GoalPersistenceIntegrationTest { @Autowired private GoalService goalService; @Autowired private JdbcTemplate jdbc; + @Autowired private GoalContinuationStore continuations; + @Autowired private PlatformTransactionManager transactionManager; + @Autowired private ApplicationEventPublisher events; private GoalCreateRequest req(String convId, String title) { GoalCreateRequest r = new GoalCreateRequest(); @@ -137,4 +150,75 @@ class GoalPersistenceIntegrationTest { assertNotNull(second); assertEquals(GoalStatus.ACTIVE, second.getStatus()); } + + @Test + void resumeCommitsGoalAndContinuationTogether() { + GoalEntity goal = persistentGoal("conv-resume-transaction", "paused"); + goalService.pause(goal.getId(), "alice"); + + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + goalService.resume(goal.getId(), "alice"); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCommit() { + inIndependentTransaction(() -> assertEquals("queued", continuations.get(goal.getId()).state(), + "Resume must commit continuation state before after-commit consumers observe it")); + } + }); + }); + + assertEquals(GoalStatus.ACTIVE, goalService.getById(goal.getId()).getStatus()); + assertEquals("queued", continuations.get(goal.getId()).state(), + "The continuation update must commit with the proxied resume transaction"); + } + + @Test + void approvalDenialAfterCommitDurablyPausesGoalAndContinuation() { + assertAfterCommitApprovalPauses("conv-denial-transaction", "USER_MANUAL", "denied"); + } + + @Test + void approvalTimeoutAfterCommitDurablyPausesGoalAndContinuation() { + assertAfterCommitApprovalPauses("conv-timeout-transaction", "TIMEOUT", null); + } + + private void assertAfterCommitApprovalPauses(String conversationId, String decisionSource, String note) { + GoalEntity goal = persistentGoal(conversationId, "waiting_approval"); + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + // Bind the JDBC resource just as ApprovalWorkflowService does while resolving approval. + jdbc.queryForObject("SELECT status FROM mate_agent_goal WHERE id=?", String.class, goal.getId()); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCommit() { + events.publishEvent(new ApprovalResolutionEvent("pending-" + goal.getId(), conversationId, + "1", "alice", "shell", "{}", null, null, decisionSource, note)); + // A different connection must see the pause before the event returns; + // do not rely on original-connection cleanup incidentally committing JDBC writes. + inIndependentTransaction(() -> { + assertEquals(GoalStatus.PAUSED, goalService.getById(goal.getId()).getStatus()); + assertEquals("paused", continuations.get(goal.getId()).state()); + }); + } + }); + }); + + assertEquals(GoalStatus.PAUSED, goalService.getById(goal.getId()).getStatus(), + "An approval callback must commit its own transaction after the approval transaction committed"); + assertEquals("paused", continuations.get(goal.getId()).state()); + } + + private void inIndependentTransaction(Runnable assertion) { + TransactionTemplate independent = new TransactionTemplate(transactionManager); + independent.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + independent.executeWithoutResult(status -> assertion.run()); + } + + private GoalEntity persistentGoal(String conversationId, String continuationState) { + GoalCreateRequest request = req(conversationId, "transaction boundary"); + request.setPersistentExecution(true); + request.setAutoFollowupEnabled(true); + GoalEntity goal = goalService.create(request, "alice"); + LocalDateTime now = LocalDateTime.now(); + jdbc.update("INSERT INTO mate_goal_continuation(goal_id,state,next_run_at,updated_at) VALUES(?,?,?,?)", + goal.getId(), continuationState, now, now); + return goal; + } } diff --git a/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalExecutionControllerTest.java b/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalExecutionControllerTest.java new file mode 100644 index 00000000..1fee7f92 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalExecutionControllerTest.java @@ -0,0 +1,27 @@ +package vip.mate.goal.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.service.GoalService; +import vip.mate.goal.service.GoalContinuationStore; +import vip.mate.workspace.conversation.ConversationService; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class GoalExecutionControllerTest { + @Test void onlyConversationOwnerMayReadExecutionState() { + var goals=mock(GoalService.class); + var store=mock(GoalContinuationStore.class); + var conversations=mock(ConversationService.class); + var goal=new GoalEntity();goal.setConversationId("private"); + when(goals.getById(1L)).thenReturn(goal); + var controller=new GoalExecutionController(goals,store,conversations); + var user=new UsernamePasswordAuthenticationToken("alice","ignored"); + assertThrows(vip.mate.exception.MateClawException.class,()->controller.execution(1L,user)); + verifyNoInteractions(store); + when(conversations.isConversationOwner("private","alice")).thenReturn(true); + controller.execution(1L,user); + verify(store).get(1L); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationStoreTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationStoreTest.java new file mode 100644 index 00000000..e74383d0 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationStoreTest.java @@ -0,0 +1,131 @@ +package vip.mate.goal.service; + +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class GoalContinuationStoreTest { + JdbcTemplate jdbc; + GoalContinuationStore store; + LocalDateTime now = LocalDateTime.of(2026, 8, 26, 12, 0); + + @BeforeEach void setup() { + JdbcDataSource ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"); + new ResourceDatabasePopulator( + new ClassPathResource("db/migration/h2/V120__agent_goal.sql"), + new ClassPathResource("db/migration/h2/V188__goal_continuation.sql")).execute(ds); + jdbc = new JdbcTemplate(ds); + store = new GoalContinuationStore(jdbc); + } + + void goal(long id, boolean persistent, String status) { + jdbc.update(""" + INSERT INTO mate_agent_goal(id,conversation_id,agent_id,workspace_id,created_by,title, + description,status,persistent_execution,auto_followup_enabled,create_time,update_time) + VALUES(?, ?,1,1,'alice','goal','full objective',?,?,TRUE,?,?) + """, id, "conv-" + id, status, persistent, now, now); + } + + @Test void discoversOnlyEligibleGoalsAndSurvivesStoreRecreation() { + goal(1, true, "active"); goal(2, false, "active"); goal(3, true, "paused"); + store.discover(now); store.discover(now); + assertEquals(1, new GoalContinuationStore(jdbc).due(now, 10).size()); + assertEquals(1L, store.due(now, 10).getFirst().goalId()); + } + + @Test void claimIsExclusiveAndSettlementIsFenced() { + goal(1, true, "active"); store.discover(now); + assertTrue(store.claim(1L, "worker-a", now, now.plusSeconds(60))); + assertFalse(store.claim(1L, "worker-b", now, now.plusSeconds(60))); + assertFalse(store.settle(1L, "worker-b", "queued", now, 0, "wrong worker")); + assertTrue(store.settle(1L, "worker-a", "retry", now.plusSeconds(10), 1, "network")); + assertTrue(store.due(now, 10).isEmpty()); + assertEquals(1, store.due(now.plusSeconds(10), 10).size()); + } + + @Test void expiredLeaseCanBeRecoveredButOldWorkerCannotSettle() { + goal(1, true, "active"); store.discover(now); + assertTrue(store.claim(1L, "old", now, now.plusSeconds(60))); + assertTrue(store.due(now.plusSeconds(59), 10).isEmpty()); + assertEquals("running", store.due(now.plusSeconds(61), 10).getFirst().state()); + assertTrue(store.claim(1L, "new", now.plusSeconds(61), now.plusSeconds(120))); + assertFalse(store.renew(1L, "old", now.plusSeconds(180))); + assertFalse(store.settle(1L, "old", "queued", now, 0, "stale")); + } + + @Test void pauseOrCompletionBetweenDiscoveryAndClaimPreventsExecution() { + goal(1, true, "active"); store.discover(now); + jdbc.update("UPDATE mate_agent_goal SET status='completed' WHERE id=1"); + assertFalse(store.claim(1L, "worker", now, now.plusSeconds(60))); + assertTrue(store.due(now, 10).isEmpty()); + } + + @Test void stopPersistsAndExplicitResumeRequeues() { + goal(1, true, "active"); store.discover(now); + store.suspendConversation("conv-1", "user_stopped"); + store.discover(now.plusDays(1)); + assertTrue(store.due(now.plusDays(1), 10).isEmpty()); + assertEquals("paused", store.get(1L).state()); + store.resume(1L, now.plusDays(1)); + assertEquals(1, store.due(now.plusDays(1), 10).size()); + } + + @Test void approvalWaitRequiresInteractiveReplayRatherThanTimerExpiry() { + goal(1,true,"active");store.discover(now); + store.claim(1L,"worker",now,now.plusSeconds(60)); + store.settle(1L,"worker","waiting_approval",now,0,"approval_required"); + assertTrue(store.due(now.plusDays(1),10).isEmpty()); + store.turnFinished("conv-1",now.plusDays(1)); + assertEquals(1,store.due(now.plusDays(1),10).size()); + } + + @Test void fastApprovalReplayCannotLoseWakeupBeforeWaitingStateIsWritten() { + goal(1,true,"active");store.discover(now); + store.claim(1L,"worker",now,now.plusSeconds(60)); + store.turnFinished("conv-1",now); + store.settle(1L,"worker","waiting_approval",now,0,"approval_required"); + assertEquals("queued",store.get(1L).state()); + assertEquals(1,store.due(now,10).size()); + } + + @Test void freshSupervisorsContinueBeyondOldFollowupCapAndStopOnCompletion() { + goal(1,true,"active"); + var goals=org.mockito.Mockito.mock(GoalService.class); + var runner=org.mockito.Mockito.mock(GoalSegmentRunner.class); + var running=new vip.mate.agent.runtime.RunningConversationRegistry(); + var streams=new vip.mate.channel.web.ChatStreamTracker(new com.fasterxml.jackson.databind.ObjectMapper()); + var properties=new vip.mate.goal.config.GoalProperties(); + var entity=new vip.mate.goal.model.GoalEntity(); + entity.setId(1L);entity.setConversationId("conv-1");entity.setStatus(vip.mate.goal.model.GoalStatus.ACTIVE); + entity.setPersistentExecution(true);entity.setAutoFollowupEnabled(true);entity.setTitle("twelve required steps"); + entity.setTurnBudget(0);entity.setLlmCallBudget(0); + org.mockito.Mockito.when(goals.getById(1L)).thenReturn(entity); + var count=new java.util.concurrent.atomic.AtomicInteger(); + org.mockito.Mockito.when(runner.run(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.anyBoolean())) + .thenAnswer(inv -> { + if(count.incrementAndGet()==12) { + entity.setStatus(vip.mate.goal.model.GoalStatus.COMPLETED); + jdbc.update("UPDATE mate_agent_goal SET status='completed' WHERE id=1"); + } + return new GoalSegmentRunner.Result("normal",false); + }); + for(int i=0;i<15;i++) { + // Recreate all scheduler state between segments, as after a server restart. + var scheduler=new GoalContinuationSupervisor(new GoalContinuationStore(jdbc),goals,properties, + new GoalFollowupService(properties,new com.fasterxml.jackson.databind.ObjectMapper()),runner,running,streams, + java.time.Clock.fixed(now.plusSeconds(i*5L).toInstant(java.time.ZoneOffset.UTC),java.time.ZoneOffset.UTC),Runnable::run); + scheduler.tick(); + } + assertEquals(12,count.get()); + assertEquals("completed",store.get(1L).state()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationSupervisorTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationSupervisorTest.java new file mode 100644 index 00000000..3f30a079 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationSupervisorTest.java @@ -0,0 +1,102 @@ +package vip.mate.goal.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import vip.mate.agent.runtime.RunningConversationRegistry; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.*; +import java.util.List; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class GoalContinuationSupervisorTest { + GoalContinuationStore store = mock(GoalContinuationStore.class); + GoalService goals = mock(GoalService.class); + GoalSegmentRunner runner = mock(GoalSegmentRunner.class); + RunningConversationRegistry running = mock(RunningConversationRegistry.class); + ChatStreamTracker streams = mock(ChatStreamTracker.class); + GoalProperties properties = new GoalProperties(); + LocalDateTime now = LocalDateTime.of(2026,8,26,12,0); + GoalEntity goal = new GoalEntity(); + GoalContinuationSupervisor supervisor; + + @BeforeEach void setup() { + goal.setId(1L); goal.setConversationId("conv"); goal.setStatus(GoalStatus.ACTIVE); + goal.setPersistentExecution(true); goal.setAutoFollowupEnabled(true); + goal.setTitle("full goal"); goal.setTurnBudget(0); goal.setLlmCallBudget(0); + when(goals.getById(1L)).thenReturn(goal); + when(store.due(any(), anyInt())).thenReturn(List.of(new GoalContinuationStore.Continuation( + 1L,"conv","queued",now,null,null,0,""))); + when(store.claim(eq(1L),anyString(),any(),any())).thenReturn(true); + when(store.settle(eq(1L),anyString(),anyString(),any(),anyInt(),anyString())).thenReturn(true); + when(runner.run(any(),anyString(),anyBoolean())).thenReturn(new GoalSegmentRunner.Result("normal",false)); + supervisor = new GoalContinuationSupervisor(store, goals, properties, + new GoalFollowupService(properties,new ObjectMapper()), runner, running, streams, + Clock.fixed(now.toInstant(ZoneOffset.UTC),ZoneOffset.UTC), Runnable::run); + } + + @Test void incompleteGoalIsRescheduledAcrossMultipleSegments() { + supervisor.tick(); supervisor.tick(); supervisor.tick(); + verify(runner,times(3)).run(eq(goal),contains("full goal"),eq(false)); + verify(store,times(3)).settle(eq(1L),anyString(),eq("queued"),any(),eq(0),anyString()); + } + + @Test void cooldownIsDurablyDeferredWithoutCallingModel() { + goal.setFollowupCooldownSeconds(60); goal.setLastFollowupAt(now.minusSeconds(10)); + supervisor.tick(); + verifyNoInteractions(runner); + verify(store).settle(eq(1L),anyString(),eq("queued"),eq(now.plusSeconds(50)),eq(0),anyString()); + } + + @Test void neverStartsAlongsideUserTurnOrQueuedInput() { + when(running.isActive("conv")).thenReturn(true); + supervisor.tick(); + verify(store,never()).claim(any(),any(),any(),any()); + verifyNoInteractions(runner); + } + + @Test void completionPreventsAnotherTurn() { + when(runner.run(any(),anyString(),anyBoolean())).thenAnswer(inv -> { + goal.setStatus(GoalStatus.COMPLETED); return new GoalSegmentRunner.Result("normal",false); + }); + supervisor.tick(); supervisor.tick(); + verify(runner).run(any(),anyString(),anyBoolean()); + verify(store).settle(eq(1L),anyString(),eq("completed"),any(),eq(0),anyString()); + } + + @Test void budgetReachedDuringSegmentIsReportedAsResumableBudgetLimit() { + when(runner.run(any(),anyString(),anyBoolean())).thenAnswer(inv -> { + goal.setStatus(GoalStatus.PAUSED); + when(goals.isBudgetExhausted(goal)).thenReturn(true); + when(goals.exhaustionReason(goal)).thenReturn("turn_budget"); + return new GoalSegmentRunner.Result("normal",false); + }); + supervisor.tick(); + verify(store).settle(eq(1L),anyString(),eq("budget_limited"),any(),eq(0),eq("turn_budget")); + } + + @Test void transientFailureGetsBackoffAndPermanentErrorBlocks() { + when(runner.run(any(),anyString(),anyBoolean())).thenThrow(new java.io.UncheckedIOException(new java.io.IOException("connection reset"))); + supervisor.tick(); + verify(store).settle(eq(1L),anyString(),eq("retry"),eq(now.plusSeconds(5)),eq(1),anyString()); + reset(store); + when(store.due(any(),anyInt())).thenReturn(List.of(new GoalContinuationStore.Continuation(1L,"conv","queued",now,null,null,0,""))); + when(store.claim(any(),any(),any(),any())).thenReturn(true); + doThrow(new IllegalArgumentException("invalid configuration")).when(runner).run(any(),anyString(),anyBoolean()); + supervisor.tick(); + verify(store).settle(eq(1L),anyString(),eq("blocked"),any(),eq(1),anyString()); + } + + @Test void approvalAndStopNeverTurnIntoAutomaticRetry() { + when(runner.run(any(),anyString(),anyBoolean())).thenReturn(new GoalSegmentRunner.Result("normal",true)); + supervisor.tick(); + verify(store).settle(eq(1L),anyString(),eq("waiting_approval"),any(),eq(0),anyString()); + when(runner.run(any(),anyString(),anyBoolean())).thenReturn(new GoalSegmentRunner.Result("stopped",false)); + supervisor.tick(); + verify(store).settle(eq(1L),anyString(),eq("paused"),any(),eq(0),anyString()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java index a9fc0f91..44a5c396 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java @@ -10,7 +10,8 @@ import vip.mate.goal.model.GoalStatus; import java.time.LocalDateTime; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; +import vip.mate.goal.model.GoalContinuationDecision.Action; /** * Covers the follow-up gating conditions. Every negative case must @@ -135,4 +136,148 @@ class GoalFollowupServiceTest { assertTrue(out.get().contains("missing X")); assertTrue(out.get().toLowerCase().contains("next concrete step")); } + private GoalEntity persistentGoal() { + GoalEntity goal = goal(true); + goal.setPersistentExecution(true); + goal.setTurnBudget(0); + goal.setLlmCallBudget(0); + return goal; + } + + @Test + void persistentUnlimitedBudgetsContinue() { + assertEquals(Action.CONTINUE, svc.decide(persistentGoal(), + res(0.6, GoalEvaluationResult.DECISION_CONTINUE), LocalDateTime.now()).action()); + } + + @Test + void persistentMayUseLastTurn_andEntireCallBudget() { + GoalEntity goal = persistentGoal(); + goal.setTurnBudget(6); + goal.setLlmCallBudget(35); + var result = res(0.6, GoalEvaluationResult.DECISION_CONTINUE); + assertEquals(Action.CONTINUE, svc.decide(goal, result, LocalDateTime.now()).action()); + goal.setTurnsUsed(6); + assertEquals(Action.BUDGET_LIMITED, svc.decide(goal, result, LocalDateTime.now()).action()); + goal.setTurnsUsed(5); + goal.setEvalLlmCallsUsed(5); + assertEquals(Action.BUDGET_LIMITED, svc.decide(goal, result, LocalDateTime.now()).action()); + } + + @Test + void cooldownReturnsExactDeadline_andContinuesAtDeadline() { + GoalEntity goal = persistentGoal(); + LocalDateTime now = LocalDateTime.of(2026, 8, 26, 12, 0); + goal.setFollowupCooldownSeconds(60); + goal.setLastFollowupAt(now.minusSeconds(10)); + var result = res(0.6, GoalEvaluationResult.DECISION_CONTINUE); + var deferred = svc.decide(goal, result, now); + assertEquals(Action.DEFER, deferred.action()); + assertEquals(now.plusSeconds(50), deferred.nextRunAt()); + assertNotNull(deferred.prompt()); + assertEquals(Action.CONTINUE, svc.decide(goal, result, now.plusSeconds(50)).action()); + } + + @Test + void fallbackIsRetry_andNeverSuccessfulCompletion() { + LocalDateTime now = LocalDateTime.of(2026, 8, 26, 12, 0); + var decision = svc.decide(persistentGoal(), GoalEvaluationResult.fallback("network"), now); + assertEquals(Action.RETRY, decision.action()); + assertNotNull(decision.nextRunAt()); + assertTrue(decision.nextRunAt().isAfter(now)); + assertTrue(decision.reason().contains("network")); + assertTrue(svc.maybeBuildFollowup(persistentGoal(), GoalEvaluationResult.fallback("network")).isEmpty()); + } + + @Test + void inactiveAndDisabledGoalsNeverContinue() { + GoalEntity goal = persistentGoal(); + var result = res(0.6, GoalEvaluationResult.DECISION_CONTINUE); + LocalDateTime now = LocalDateTime.now(); + for (GoalStatus status : new GoalStatus[]{GoalStatus.PAUSED, GoalStatus.ABANDONED, GoalStatus.EXHAUSTED}) { + goal.setStatus(status); + assertEquals(Action.DISABLED, svc.decide(goal, result, now).action()); + } + goal.setStatus(GoalStatus.COMPLETED); + assertEquals(Action.COMPLETE, svc.decide(goal, result, now).action()); + goal.setStatus(GoalStatus.ACTIVE); + goal.setAutoFollowupEnabled(false); + assertEquals(Action.DISABLED, svc.decide(goal, result, now).action()); + goal.setAutoFollowupEnabled(true); + properties.setEnabled(false); + assertEquals(Action.DISABLED, svc.decide(goal, result, now).action()); + } + + @Test + void authoritativeCompletionWinsAtBudgetLimit() { + GoalEntity goal = persistentGoal(); + goal.setTurnBudget(5); + goal.setCriteria("[{\"id\":\"C1\",\"text\":\"deployed\",\"passed\":true,\"evidence\":\"verified\"}]"); + var result = new GoalEvaluationResult(1, "verified", GoalEvaluationResult.DECISION_COMPLETED, + true, "stub", 1, 0, java.util.List.of(), null); + assertEquals(Action.COMPLETE, svc.decide(goal, result, LocalDateTime.now()).action()); + } + + @Test + void promptPreservesObjectiveAndRemainingChecklist_andGuardsSideEffects() { + GoalEntity goal = persistentGoal(); + goal.setDescription("Deploy the original blog"); + goal.setExitCriteria("Public URL responds"); + goal.setCriteria("[{\"id\":\"C1\",\"text\":\"already satisfied\",\"passed\":true,\"evidence\":\"verified\"}," + + "{\"id\":\"C2\",\"text\":\"verify production\",\"passed\":false}]"); + String prompt = svc.decide(goal, res(0.6, GoalEvaluationResult.DECISION_CONTINUE), + LocalDateTime.now()).prompt(); + assertTrue(prompt.contains("ship")); + assertTrue(prompt.contains("Deploy the original blog")); + assertTrue(prompt.contains("Public URL responds")); + assertTrue(prompt.contains("verify production")); + assertFalse(prompt.contains("already satisfied")); + assertTrue(prompt.contains("original objective")); + assertTrue(prompt.contains("authoritative state")); + assertTrue(prompt.contains("async handles")); + assertTrue(prompt.contains("side effects")); + } + + @Test + void promptHasBoundedSize_evenForLargeGoalAndChecklist() { + GoalEntity goal = persistentGoal(); + goal.setTitle("T".repeat(10000)); + goal.setDescription("D".repeat(10000)); + goal.setExitCriteria("E".repeat(10000)); + goal.setCriteria("[{\"id\":\"C1\",\"text\":\"" + "C".repeat(20000) + "\",\"passed\":false}]"); + String prompt = svc.decide(goal, res(0.6, GoalEvaluationResult.DECISION_CONTINUE), + LocalDateTime.now()).prompt(); + assertTrue(prompt.length() <= 12000); + assertTrue(prompt.contains("authoritative state")); + assertTrue(prompt.contains("next concrete step")); + } + @Test + void passedCriterionWithoutEvidenceRemainsInPersistentPrompt() { + GoalEntity goal = persistentGoal(); + goal.setCriteria("[{\"id\":\"C1\",\"text\":\"verify deployed endpoint\",\"passed\":true,\"evidence\":\" \"}]"); + var decision = svc.decide(goal, res(1, GoalEvaluationResult.DECISION_CONTINUE), LocalDateTime.now()); + assertEquals(Action.CONTINUE, decision.action()); + assertTrue(decision.prompt().contains("verify deployed endpoint")); + } + @Test + void persistentCompletionDecisionRetriesWithoutAuthoritativeEvidence() { + GoalEntity goal = persistentGoal(); + goal.setCriteria("[{\"id\":\"C1\",\"text\":\"deployed\",\"passed\":true,\"evidence\":\"\"}]"); + var result = new GoalEvaluationResult(1, "claimed complete", GoalEvaluationResult.DECISION_COMPLETED, + true, "stub", 1, 0, java.util.List.of(), null); + var decision = svc.decide(goal, result, LocalDateTime.now()); + assertEquals(Action.RETRY, decision.action()); + assertEquals("completion_not_verified", decision.reason()); + assertTrue(decision.prompt().contains("deployed")); + } + @Test + void persistentPromptExplainsEssentialInputBoundary() { + String prompt = svc.decide(persistentGoal(), res(0.5, GoalEvaluationResult.DECISION_CONTINUE), + LocalDateTime.now()).prompt(); + assertTrue(prompt.contains("waitForGoalInput")); + assertTrue(prompt.contains("essential input")); + assertTrue(prompt.contains("permission")); + assertTrue(prompt.contains("difficulty")); + assertTrue(prompt.contains("time")); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalSegmentRunnerTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalSegmentRunnerTest.java new file mode 100644 index 00000000..e695482c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalSegmentRunnerTest.java @@ -0,0 +1,193 @@ +package vip.mate.goal.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import vip.mate.agent.AgentService; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.runtime.ConversationTurnGate; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.goal.model.GoalEntity; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.ConversationEntity; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class GoalSegmentRunnerTest { + AgentService agents=mock(AgentService.class); + ConversationService conversations=mock(ConversationService.class); + ApprovalWorkflowService approvals=mock(ApprovalWorkflowService.class); + ChatStreamTracker streams=new ChatStreamTracker(new ObjectMapper()); + ConversationTurnGate gate=new ConversationTurnGate(); + GoalEntity goal=new GoalEntity(); + GoalSegmentRunner runner=new GoalSegmentRunner(agents,conversations,approvals,streams,new ObjectMapper(),gate); + + @BeforeEach void setup() { + goal.setId(1L);goal.setConversationId("conv");goal.setAgentId(2L);goal.setWorkspaceId(3L);goal.setCreatedBy("alice"); + ConversationEntity conv=new ConversationEntity(); + conv.setConversationId("conv");conv.setAgentId(2L);conv.setWorkspaceId(3L);conv.setUsername("alice"); + when(conversations.findByConversationId("conv")).thenReturn(conv); + AgentEntity agent=new AgentEntity();agent.setEnabled(true);agent.setRuntimeType("native"); + when(agents.getAgent(2L)).thenReturn(agent); + } + + @Test void persistsStreamedResultAndUsage() { + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenReturn(Flux.just(new AgentService.StreamDelta("actual output",null), + AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal")))); + var result=runner.run(goal,"continue",false); + assertEquals("normal",result.finishReason()); + verify(conversations).saveMessage(eq("conv"),eq("assistant"),eq("actual output"),anyList(),eq("completed"), + anyInt(),anyInt(),anyInt(),anyInt(),anyInt(),anyString(),anyString(),anyString()); + assertFalse(streams.isRunning("conv")); + assertNotNull(gate.tryAcquire("conv")); + } + + @Test void rejectsChangedConversationIdentityBeforeAnyModelCall() { + goal.setWorkspaceId(99L); + assertThrows(IllegalStateException.class,()->runner.run(goal,"continue",false)); + verify(agents,never()).chatStructuredStream(any(),any(),any(),any(),any(),any()); + } + + @Test void busyConversationIsNotRegisteredOrMutated() { + var user=gate.tryAcquire("conv"); + assertThrows(vip.mate.exception.MateClawException.class,()->runner.run(goal,"continue",false)); + verifyNoInteractions(conversations); + user.close(); + } + + @Test void drainsUserInputAcceptedDuringBackgroundTurn() { + var calls=new java.util.concurrent.atomic.AtomicInteger(); + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenAnswer(inv -> Flux.defer(() -> { + if(calls.incrementAndGet()==1) streams.enqueueMessage("conv","new user instruction",2L,false); + return Flux.just(new AgentService.StreamDelta("output",null), + AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal"))); + })); + runner.run(goal,"continue",false); + assertEquals(2,calls.get()); + assertFalse(streams.hasQueuedMessage("conv")); + verify(agents).chatStructuredStream(eq(2L),eq("new user instruction"),eq("conv"),eq("alice"),isNull(),any()); + verify(conversations).saveMessage("conv","user","new user instruction",null,"queued"); + } + + @Test void workerCancellationPersistsPartialEvidenceAndReleasesAdmission() throws Exception { + var subscribed=new java.util.concurrent.CountDownLatch(1); + var toolCancelled=new java.util.concurrent.atomic.AtomicBoolean(); + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenReturn(Flux.concat(Flux.just(new AgentService.StreamDelta("partial evidence",null)), + Flux.never().doOnSubscribe(s -> { + streams.registerCancellationHook("conv",()->toolCancelled.set(true)); + subscribed.countDown(); + }))); + var failure=new java.util.concurrent.atomic.AtomicReference(); + var result=new java.util.concurrent.atomic.AtomicReference(); + Thread worker=Thread.ofVirtual().start(() -> { + try { result.set(runner.run(goal,"continue",false)); } catch(Throwable error) { failure.set(error); } + }); + assertTrue(subscribed.await(3,java.util.concurrent.TimeUnit.SECONDS)); + runner.cancel(1L); + worker.join(3000); + assertFalse(worker.isAlive()); + assertTrue(toolCancelled.get(),"escaped tool process must be cancelled as well as the stream"); + // Cancellation may finish the Flux before the worker receives its interrupt. + // Both paths must preserve evidence and report a stopped/interrupted outcome. + if (failure.get()==null) assertEquals("stopped",result.get().finishReason()); + verify(conversations).saveMessage(eq("conv"),eq("assistant"),eq("partial evidence"),anyList(), + argThat(status -> "interrupted".equals(status) || "stopped".equals(status)), + anyInt(),anyInt(),anyInt(),anyInt(),anyInt(),anyString(),anyString(),anyString()); + assertNotNull(gate.tryAcquire("conv")); + } + + @Test void permanentFailurePersistsAcceptedQueuedInput() { + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenReturn(Flux.defer(() -> { + streams.enqueueMessage("conv","user instruction",2L,false); + return Flux.error(new IllegalArgumentException("bad config")); + })); + assertThrows(IllegalArgumentException.class,()->runner.run(goal,"continue",false)); + verify(conversations).saveMessage("conv","user","user instruction",null,"queued"); + assertFalse(streams.hasQueuedMessage("conv")); + } + + @Test void userSteeringPreservesInterruptedStatusThenRunsQueuedInput() throws Exception { + var subscribed=new java.util.concurrent.CountDownLatch(1); + var calls=new java.util.concurrent.atomic.AtomicInteger(); + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenAnswer(inv -> calls.incrementAndGet()==1 + ? Flux.concat(Flux.just(new AgentService.StreamDelta("partial",null)), + Flux.never().doOnSubscribe(s -> subscribed.countDown())) + : Flux.just(new AgentService.StreamDelta("answer to steering",null))); + var failure=new java.util.concurrent.atomic.AtomicReference(); + Thread worker=Thread.ofVirtual().start(() -> { + try { runner.run(goal,"continue",false); } catch(Throwable error) { failure.set(error); } + }); + assertTrue(subscribed.await(3,java.util.concurrent.TimeUnit.SECONDS)); + assertTrue(streams.requestInterrupt("conv","new instruction",2L,false)); + worker.join(3000); + assertFalse(worker.isAlive()); + assertNull(failure.get()); + assertEquals(2,calls.get()); + verify(conversations).saveMessage(eq("conv"),eq("assistant"),eq("partial"),anyList(),eq("interrupted"), + anyInt(),anyInt(),anyInt(),anyInt(),anyInt(),anyString(),anyString(),anyString()); + } + + @Test void goalCancellationDoesNotKillQueuedInteractiveWork() throws Exception { + var entered=new java.util.concurrent.CountDownLatch(1); + var finish=reactor.core.publisher.Sinks.one(); + var calls=new java.util.concurrent.atomic.AtomicInteger(); + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenAnswer(inv -> { + if(calls.incrementAndGet()==1) { + streams.enqueueMessage("conv","new question",2L,false); + return Flux.just(AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal"))); + } + return finish.asMono().flux().doOnSubscribe(s -> entered.countDown()); + }); + var failure=new java.util.concurrent.atomic.AtomicReference(); + Thread worker=Thread.ofVirtual().start(() -> { + try { runner.run(goal,"continue",false); } catch(Throwable error) { failure.set(error); } + }); + assertTrue(entered.await(3,java.util.concurrent.TimeUnit.SECONDS)); + runner.cancel(1L); + finish.tryEmitValue(AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal"))); + worker.join(3000); + assertFalse(worker.isAlive()); + assertNull(failure.get()); + } + + @Test void explicitStopLatchesAcrossQueuedInputRegistrationGap() throws Exception { + var saving=new java.util.concurrent.CountDownLatch(1); + var release=new java.util.concurrent.CountDownLatch(1); + var calls=new java.util.concurrent.atomic.AtomicInteger(); + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenAnswer(inv -> { + calls.incrementAndGet(); + streams.enqueueMessage("conv","new question",2L,false); + return Flux.just(AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal"))); + }); + when(conversations.saveMessage("conv","user","new question",null,"queued")).thenAnswer(inv -> { + saving.countDown(); + boolean interrupted=false; + while(true) { + try { if(release.await(3,java.util.concurrent.TimeUnit.SECONDS)) break; else throw new AssertionError("release timeout"); } + catch(InterruptedException ignored) { interrupted=true; } + } + if(interrupted) Thread.currentThread().interrupt(); + return null; + }); + Thread worker=Thread.ofVirtual().start(() -> { + try { runner.run(goal,"continue",false); } catch(RuntimeException expected) { } + }); + assertTrue(saving.await(3,java.util.concurrent.TimeUnit.SECONDS)); + runner.stopConversation("conv"); + release.countDown(); + worker.join(3000); + assertFalse(worker.isAlive()); + assertEquals(1,calls.get(),"no queued model request may start after explicit Stop"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java index ee39d3cd..e7797bc5 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java @@ -55,6 +55,7 @@ class GoalServiceTest { @Mock private AuditEventService auditEventService; private GoalServiceImpl service; + private GoalProperties properties; @BeforeAll static void initTableInfo() { @@ -68,7 +69,7 @@ class GoalServiceTest { @BeforeEach void setUp() { - GoalProperties properties = new GoalProperties(); + properties = new GoalProperties(); service = new GoalServiceImpl(goalMapper, eventMapper, properties, auditEventService, new ObjectMapper()); } @@ -122,8 +123,9 @@ class GoalServiceTest { assertNotNull(created); assertEquals("alice", created.getCreatedBy()); assertEquals(GoalStatus.ACTIVE, created.getStatus()); - assertEquals(20, created.getTurnBudget()); - assertEquals(200, created.getLlmCallBudget()); + assertTrue(created.getPersistentExecution()); + assertEquals(0, created.getTurnBudget()); + assertEquals(0, created.getLlmCallBudget()); verify(eventMapper, times(1)).insert(any(GoalEventEntity.class)); verify(auditEventService).record(eq("goal.created"), eq("goal"), anyString(), anyString(), anyString(), any()); @@ -162,12 +164,235 @@ class GoalServiceTest { @Test void create_rejectsNonPositiveBudget() { GoalCreateRequest r = validReq(); + r.setPersistentExecution(false); r.setTurnBudget(0); MateClawException ex = assertThrows(MateClawException.class, () -> service.create(r, "alice")); assertEquals(400, ex.getCode()); } + @Test + void create_persistenceDefaultCanBeDisabled_andExplicitOptInWins() { + properties.setDefaultPersistentExecution(false); + GoalEntity legacy = service.create(validReq(), "alice"); + assertFalse(legacy.getPersistentExecution()); + assertEquals(20, legacy.getTurnBudget()); + assertEquals(200, legacy.getLlmCallBudget()); + GoalCreateRequest req = validReq(); + req.setPersistentExecution(true); + GoalEntity persistent = service.create(req, "alice"); + assertTrue(persistent.getPersistentExecution()); + assertEquals(0, persistent.getTurnBudget()); + assertEquals(0, persistent.getLlmCallBudget()); + assertTrue(service.toResponse(persistent).getPersistentExecution()); + } + + @Test + void create_explicitLegacyRetainsDefaults() { + GoalCreateRequest req = validReq(); + req.setPersistentExecution(false); + GoalEntity goal = service.create(req, "alice"); + assertFalse(goal.getPersistentExecution()); + assertEquals(20, goal.getTurnBudget()); + assertEquals(200, goal.getLlmCallBudget()); + } + + @Test + void create_persistentAcceptsZero_andHonorsPositiveBudgets() { + GoalCreateRequest req = validReq(); + req.setTurnBudget(0); + req.setLlmCallBudget(7); + GoalEntity goal = service.create(req, "alice"); + assertEquals(0, goal.getTurnBudget()); + assertEquals(7, goal.getLlmCallBudget()); + req.setTurnBudget(-1); + assertEquals(400, assertThrows(MateClawException.class, + () -> service.create(req, "alice")).getCode()); + req.setTurnBudget(1); + req.setLlmCallBudget(-1); + assertEquals(400, assertThrows(MateClawException.class, + () -> service.create(req, "alice")).getCode()); + } + + @Test + void persistentZeroBudgetsAreUnlimited_butLegacyZeroIsExhausted() { + GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); + goal.setPersistentExecution(true); + goal.setTurnBudget(0); + goal.setLlmCallBudget(0); + goal.setTurnsUsed(999); + goal.setAgentLlmCallsUsed(999); + assertFalse(service.isBudgetExhausted(goal)); + goal.setPersistentExecution(false); + assertTrue(service.isBudgetExhausted(goal)); + } + + @Test + void persistentPositiveBudgetsRemainBinding() { + GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); + goal.setPersistentExecution(true); + goal.setTurnBudget(0); + goal.setLlmCallBudget(10); + goal.setAgentLlmCallsUsed(9); + assertFalse(service.isBudgetExhausted(goal)); + goal.setEvalLlmCallsUsed(1); + assertTrue(service.isBudgetExhausted(goal)); + assertEquals("llm_call_budget", service.exhaustionReason(goal)); + goal.setTurnBudget(3); + goal.setTurnsUsed(3); + assertEquals("turn_budget", service.exhaustionReason(goal)); + } + + @Test + void persistentBudgetExhaustionPauses_withResumableReason() { + GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); + goal.setPersistentExecution(true); + goal.setTurnsUsed(20); + when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.PAUSED)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + GoalEntity result = service.markExhausted(1L, "turn_budget"); + assertEquals(GoalStatus.PAUSED, result.getStatus()); + ArgumentCaptor event = ArgumentCaptor.forClass(GoalEventEntity.class); + verify(eventMapper).insert(event.capture()); + assertEquals("paused", event.getValue().getEventType()); + assertTrue(event.getValue().getDetailJson().contains("turn_budget")); + ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), update.capture()); + assertTrue(update.getValue().getSqlSet().contains("progress_summary")); + assertTrue(update.getValue().getParamNameValuePairs().values().stream() + .anyMatch(value -> String.valueOf(value).contains("turn_budget"))); + } + + @Test + void resumePersistentRequiresBudgetHeadroom_andAllowsRaisedBudget() { + GoalEntity goal = persisted(1L, GoalStatus.PAUSED); + goal.setPersistentExecution(true); + goal.setTurnsUsed(20); + when(goalMapper.selectById(1L)).thenReturn(goal); + assertEquals(409, assertThrows(MateClawException.class, + () -> service.resume(1L, "alice")).getCode()); + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + goal.setTurnBudget(21); + when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.ACTIVE)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + assertEquals(GoalStatus.ACTIVE, service.resume(1L, "alice").getStatus()); + } + + @Test + void updateUsesFreshMode_forZeroBudget_andDoesNotReplaceOmittedBudgets() { + GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); + goal.setPersistentExecution(true); + when(goalMapper.selectById(1L)).thenReturn(goal); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + GoalUpdateRequest req = new GoalUpdateRequest(); + req.setTurnBudget(0); + service.update(1L, req, "alice"); + ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), update.capture()); + assertTrue(setsProperty(update.getValue(), "turnBudget"), update.getValue().getSqlSet()); + assertFalse(setsProperty(update.getValue(), "llmCallBudget")); + assertFalse(setsProperty(update.getValue(), "persistentExecution")); + } + + @Test + void updateModeValidatesCombinedState_beforeWriting() { + GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); + goal.setPersistentExecution(true); + goal.setTurnBudget(0); + goal.setLlmCallBudget(0); + when(goalMapper.selectById(1L)).thenReturn(goal); + GoalUpdateRequest req = new GoalUpdateRequest(); + req.setPersistentExecution(false); + assertEquals(400, assertThrows(MateClawException.class, + () -> service.update(1L, req, "alice")).getCode()); + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + req.setTurnBudget(10); + req.setLlmCallBudget(100); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + service.update(1L, req, "alice"); + ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), update.capture()); + assertTrue(setsProperty(update.getValue(), "persistentExecution"), update.getValue().getSqlSet()); + } + + @Test + void persistentCompletionRequiresFreshPassedCriteriaWithEvidence() { + GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); + goal.setPersistentExecution(true); + when(goalMapper.selectById(1L)).thenReturn(goal); + for (String criteria : new String[]{null, "[]", + "[{\"id\":\"C1\",\"text\":\"deploy\",\"passed\":false,\"evidence\":\"attempted\"}]", + "[{\"id\":\"C1\",\"text\":\"deploy\",\"passed\":true,\"evidence\":\" \"}]"}) { + goal.setCriteria(criteria); + MateClawException error = assertThrows(MateClawException.class, + () -> service.markCompleted(1L, null)); + assertEquals(409, error.getCode()); + } + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + } + + @Test + void persistentCompletionCannotOverridePause() { + GoalEntity goal = verifiedPersistentGoal(GoalStatus.PAUSED); + when(goalMapper.selectById(1L)).thenReturn(goal); + assertEquals(409, assertThrows(MateClawException.class, + () -> service.markCompleted(1L, null)).getCode()); + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + } + + @Test + void persistentCompletionPreservesVerifiedChecklist() { + GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.COMPLETED)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + assertEquals(GoalStatus.COMPLETED, service.markCompleted(1L, null).getStatus()); + ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), update.capture()); + assertFalse(update.getValue().getSqlSet().contains("criteria=")); + } + + @Test + void persistentCompletionRechecksEvidenceAfterCasMiss() { + GoalEntity old = verifiedPersistentGoal(GoalStatus.ACTIVE); + GoalEntity fresh = verifiedPersistentGoal(GoalStatus.ACTIVE); + fresh.setVersion(1); + fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"new requirement\",\"passed\":false,\"evidence\":\"\"}]"); + when(goalMapper.selectById(1L)).thenReturn(old, fresh); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0); + assertEquals(409, assertThrows(MateClawException.class, + () -> service.markCompleted(1L, null)).getCode()); + verify(goalMapper, times(1)).update(any(), any(LambdaUpdateWrapper.class)); + } + + @Test + void persistentResumePublishesSignalOnlyAfterSuccessfulTransition() { + var publisher = org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class); + service.setApplicationEventPublisher(publisher); + GoalEntity goal = verifiedPersistentGoal(GoalStatus.PAUSED); + goal.setTurnsUsed(20); + when(goalMapper.selectById(1L)).thenReturn(goal); + assertThrows(MateClawException.class, () -> service.resume(1L, "alice")); + verify(publisher, never()).publishEvent(any(Object.class)); + goal.setTurnBudget(21); + when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.ACTIVE)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + service.resume(1L, "alice"); + verify(publisher).publishEvent(new GoalExecutionSignal.Resume(1L)); + } + + private boolean setsProperty(LambdaUpdateWrapper update, String property) { + String column = TableInfoHelper.getTableInfo(GoalEntity.class).getFieldList().stream() + .filter(field -> property.equals(field.getProperty())).findFirst().orElseThrow().getColumn(); + return update.getSqlSet().contains(column + "="); + } + + private GoalEntity verifiedPersistentGoal(GoalStatus status) { + GoalEntity goal = persisted(1L, status); + goal.setPersistentExecution(true); + goal.setCriteria("[{\"id\":\"C1\",\"text\":\"deploy\",\"passed\":true,\"evidence\":\"HTTP 200 verified\"}]"); + return goal; + } + // ==================== state transitions ==================== @Test @@ -454,6 +679,7 @@ class GoalServiceTest { copy.setCreatedBy(g.getCreatedBy()); copy.setTitle(g.getTitle()); copy.setStatus(newStatus); + copy.setPersistentExecution(g.getPersistentExecution()); copy.setTurnBudget(g.getTurnBudget()); copy.setTurnsUsed(g.getTurnsUsed()); copy.setLlmCallBudget(g.getLlmCallBudget()); @@ -465,4 +691,90 @@ class GoalServiceTest { copy.setUpdateTime(LocalDateTime.now()); return copy; } + @Test + void waitForInputPausesActivePersistentGoal_andRecordsReason() { + GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.PAUSED)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + GoalEntity paused = service.waitForInput(1L, " Need production hostname from the owner ", "alice"); + assertEquals(GoalStatus.PAUSED, paused.getStatus()); + ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), update.capture()); + assertTrue(setsProperty(update.getValue(), "progressSummary")); + assertTrue(update.getValue().getParamNameValuePairs().containsValue( + "Waiting for input: Need production hostname from the owner")); + assertTrue(update.getValue().getParamNameValuePairs().containsValue(GoalStatus.PAUSED)); + ArgumentCaptor event = ArgumentCaptor.forClass(GoalEventEntity.class); + verify(eventMapper).insert(event.capture()); + assertEquals("paused", event.getValue().getEventType()); + assertTrue(event.getValue().getDetailJson().contains("Need production hostname from the owner")); + verify(auditEventService).record(eq("goal.waiting_input"), eq("goal"), eq("1"), + anyString(), anyString(), any()); + } + + @Test + void waitForInputRejectsBlankReasonBeforeAccessingGoal() { + for (String reason : new String[]{null, "", " "}) { + assertEquals(400, assertThrows(MateClawException.class, + () -> service.waitForInput(1L, reason, "alice")).getCode()); + } + verify(goalMapper, never()).selectById(any()); + } + + @Test + void waitForInputRechecksActiveStateAfterCasConflict() { + GoalEntity active = verifiedPersistentGoal(GoalStatus.ACTIVE); + GoalEntity paused = statusFlipped(active, GoalStatus.PAUSED); + when(goalMapper.selectById(1L)).thenReturn(active, paused); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0); + assertEquals(409, assertThrows(MateClawException.class, + () -> service.waitForInput(1L, "Need deployment approval", "alice")).getCode()); + verify(goalMapper, times(1)).update(any(), any(LambdaUpdateWrapper.class)); + verify(eventMapper, never()).insert(any(GoalEventEntity.class)); + } + + @Test + void waitForInputRejectsLegacyAndTerminalGoals() { + GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(goal); + assertEquals(409, assertThrows(MateClawException.class, + () -> service.waitForInput(1L, "Need deployment approval", "alice")).getCode()); + goal.setPersistentExecution(true); + goal.setStatus(GoalStatus.COMPLETED); + assertEquals(409, assertThrows(MateClawException.class, + () -> service.waitForInput(1L, "Need deployment approval", "alice")).getCode()); + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + } + + @Test + void waitForInputBoundsPersistedReason() { + GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.PAUSED)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + service.waitForInput(1L, "Missing permission: " + "x".repeat(10000), "alice"); + ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), update.capture()); + update.getValue().getSqlSet(); + String summary = ((java.util.Map) update.getValue().getParamNameValuePairs()).values().stream() + .filter(value -> value instanceof String && ((String) value).startsWith("Waiting for input: ")) + .map(String::valueOf).findFirst().orElseThrow(); + assertTrue(summary.length() <= 2048); + } + + @Test + void lateEvaluationAccountsUsageWithoutOverwritingPersistentPauseReason() { + GoalEntity active = verifiedPersistentGoal(GoalStatus.ACTIVE); + GoalEntity paused = statusFlipped(active, GoalStatus.PAUSED); + paused.setProgressSummary("Waiting for input: Need deployment approval"); + when(goalMapper.selectById(1L)).thenReturn(active, paused, paused); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + GoalEvaluationResult evaluation = new GoalEvaluationResult(0.4, "More work needed", + GoalEvaluationResult.DECISION_CONTINUE, false, "stub", 1, 0, java.util.List.of(), null); + service.recordEvaluation(1L, evaluation, 3, 1); + ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), update.capture()); + assertFalse(setsProperty(update.getValue(), "progressSummary")); + assertTrue(update.getValue().getSqlSet().contains("agent_llm_calls_used = agent_llm_calls_used + 3")); + assertTrue(update.getValue().getSqlSet().contains("eval_llm_calls_used = eval_llm_calls_used + 1")); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java index 39fc2adf..1fdef1d7 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java @@ -78,6 +78,7 @@ class DelegateAgentToolDenyListTest { // Memory writers (canonical Spring AI tool method names — do not include // any speculative names that would silently no-op). assertThat(defaults).contains("remember", "remember_structured", "forget_structured"); + assertThat(defaults).contains("waitForGoalInput"); // Shell stays out by design — see comment on DEFAULT_CHILD_DENIED_TOOLS. assertThat(defaults).doesNotContain("execute_shell_command"); } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java index d98653d6..0e9dcf20 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java @@ -178,4 +178,57 @@ class GoalManagementToolTest { // total = agent(12) + eval(2) assertTrue(result.contains("\"totalLlmCallsUsed\":14")); } + @Test + void waitForGoalInputRequiresReasonAndBoundContext() { + ToolContext ctx = ctxWith("conv-1", 10L, "alice"); + assertTrue(tool.waitForGoalInput(" ", ctx).contains("reason")); + assertTrue(tool.waitForGoalInput("Need hostname", null).contains("bound conversation")); + verify(goalService, never()).findActiveByConversation(anyString()); + verify(goalService, never()).waitForInput(any(), anyString(), anyString()); + } + + @Test + void waitForGoalInputRequiresEnabledPersistentActiveGoal() { + ToolContext ctx = ctxWith("conv-1", 10L, "alice"); + properties.setEnabled(false); + assertTrue(tool.waitForGoalInput("Need hostname", ctx).contains("disabled")); + verify(goalService, never()).findActiveByConversation(anyString()); + properties.setEnabled(true); + GoalEntity goal = goal(GoalStatus.ACTIVE); + when(goalService.findActiveByConversation("conv-1")).thenReturn(goal); + assertTrue(tool.waitForGoalInput("Need hostname", ctx).contains("persistent")); + goal.setPersistentExecution(true); + goal.setStatus(GoalStatus.PAUSED); + assertTrue(tool.waitForGoalInput("Need hostname", ctx).contains("active goal")); + verify(goalService, never()).waitForInput(any(), anyString(), anyString()); + } + + @Test + void waitForGoalInputPausesBoundGoal_andBroadcastsUpdatedState() { + GoalEntity active = goal(GoalStatus.ACTIVE); + active.setPersistentExecution(true); + GoalEntity paused = goal(GoalStatus.PAUSED); + paused.setPersistentExecution(true); + paused.setProgressSummary("Waiting for input: Need the deployment hostname"); + when(goalService.findActiveByConversation("conv-1")).thenReturn(active); + when(goalService.waitForInput(123L, "Need the deployment hostname", "alice")).thenReturn(paused); + when(goalService.toResponse(paused)).thenReturn(new vip.mate.goal.model.GoalResponse()); + String result = tool.waitForGoalInput(" Need the deployment hostname ", ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("\"status\":\"paused\"")); + assertTrue(result.contains("Need the deployment hostname")); + verify(streamTracker).broadcastObject(eq("conv-1"), eq("goal_updated"), any()); + verify(goalService, never()).markCompleted(any(), any()); + } + + @Test + void waitForGoalInputReturnsErrorIfStateChangedBeforePause() { + GoalEntity active = goal(GoalStatus.ACTIVE); + active.setPersistentExecution(true); + when(goalService.findActiveByConversation("conv-1")).thenReturn(active); + when(goalService.waitForInput(123L, "Need hostname", "alice")) + .thenThrow(new MateClawException("err.goal.bad_transition", 409, "Goal no longer active")); + assertTrue(tool.waitForGoalInput("Need hostname", ctxWith("conv-1", 10L, "alice")).contains("Goal no longer active")); + verify(streamTracker, never()).broadcastObject(anyString(), anyString(), any()); + } + } diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index dffb395e..0844dee2 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -1789,6 +1789,8 @@ export interface Goal { description: string exitCriteria?: string | null status: 'active' | 'paused' | 'completed' | 'abandoned' | 'exhausted' + /** Durable continuation; zero budgets are unlimited only in this mode. */ + persistentExecution?: boolean turnBudget: number turnsUsed: number llmCallBudget: number @@ -1824,6 +1826,7 @@ export const goalApi = { title: string description?: string exitCriteria?: string + persistentExecution?: boolean turnBudget?: number llmCallBudget?: number autoFollowupEnabled?: boolean