From 69091fe28d75d62dcddbae00a6b61e2cfcf3f090 Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Tue, 15 Sep 2026 01:11:50 +0800 Subject: [PATCH] fix(goal): preserve recovery guidance until execution starts --- .../service/GoalContinuationSupervisor.java | 6 +++++- .../mate/goal/service/GoalRunCoordinator.java | 12 +++++++++--- .../docs/en/managed-json-acceptance.md | 2 ++ .../docs/zh/managed-json-acceptance.md | 2 ++ .../GoalJsonHttpRuntimeIntegrationTest.java | 5 +++++ .../GoalContinuationSupervisorTest.java | 19 +++++++++++++++++++ .../goal/service/GoalRecoveryServiceTest.java | 19 +++++++++++++++++++ 7 files changed, 61 insertions(+), 4 deletions(-) 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 index 05b97343..3c288f93 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationSupervisor.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationSupervisor.java @@ -124,7 +124,11 @@ public class GoalContinuationSupervisor { case CONTINUE -> { } } if(!coordinator.markRunning(claimed,now)) return; - SegmentOutcome outcome = runner.run(claimed,decision.prompt(),"running".equals(claimed.candidate().state())); + // Recovery requeues the projection as retry and gives the new attempt + // a durable parent; the old running-state check alone loses its guidance. + boolean recovered = claimed.attempt().parentAttemptId()!=null + || "running".equals(claimed.candidate().state()); + SegmentOutcome outcome = runner.run(claimed,decision.prompt(),recovered); if (outcome instanceof SegmentOutcome.Retry retry && ("provider".equals(retry.category()) || "evaluation".equals(retry.category()))) { activateProviderBackoff(LocalDateTime.now(clock)); diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalRunCoordinator.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalRunCoordinator.java index 7a1bd8b3..d8e29e69 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalRunCoordinator.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalRunCoordinator.java @@ -49,9 +49,15 @@ public class GoalRunCoordinator { if(!continuations.claim(goal.getId(),token,now,until,nowEpoch,untilEpoch)) return null; GoalContinuationStore.Continuation claimed=continuations.get(goal.getId()); String parentAttemptId=null; - if("restart_recovery".equals(candidate.reason())) { - var recent=attempts.listRecent(goal.getId(),1); - if(!recent.isEmpty()) parentAttemptId=recent.getFirst().id(); + var recent=attempts.listRecent(goal.getId(),1); + if(!recent.isEmpty()) { + var previous=recent.getFirst(); + // A recovered attempt may be deferred before reaching the provider. + // Keep that pending recovery context until a segment actually starts. + if("restart_recovery".equals(candidate.reason()) + || previous.parentAttemptId()!=null && "claimed".equals(previous.checkpointType())) { + parentAttemptId=previous.id(); + } } GoalAttempt attempt=attempts.create(goal.getId(),goal.getConversationId(),parentAttemptId, "continuation",token,until,null,now,untilEpoch); diff --git a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md index f7ebe72a..94026628 100644 --- a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md @@ -57,4 +57,6 @@ Validation snapshot (2026-09-14): the full default backend test run passed 5,249 Built-in shell/code execution is not OS-isolated from the service host. Selecting JSON acceptance does not sandbox those tools, and the protocol cannot defend against host code that can access database credentials or files. Environment-name filtering and workspace path checks do not replace that isolation. Lease deadlines are calculated from absolute instants, including daylight-saving clock rollback; scheduling display fields remain local timestamps. +Recovery attempts receive guidance to inspect existing evidence before repeating work. If the first recovered segment is deferred before execution, its recovery context is retained for the next claim. Ordinary continuation after an executed segment does not become a new recovery. + From V199, queued Web input stores the authenticated account ID at enqueue time, and ordinary Web replay carries the conversation workspace. Managed operations still recheck the account, ownership and current requirements. Legacy queue items do not gain an asserted identity from a username; users must resend an authenticated request for managed JSON operations. Persistent Goal workers retain their existing attempt-owner validation when consuming input; this does not introduce an account path without a lease check. diff --git a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md index 888ce0ec..df921db8 100644 --- a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md @@ -58,3 +58,5 @@ V198 同样以绝对时间保存调度租约截止。升级时旧租约失效, 内置 shell/code 执行没有与服务宿主做操作系统隔离。选择 JSON 验收不会把这些工具变成沙箱;此协议不能抵抗能访问数据库凭据或文件的宿主代码,环境变量名称过滤和工作区路径检查也不能替代隔离。租约截止从绝对时刻计算,覆盖夏令时回拨;调度显示字段仍使用本地时间戳。 Web排队消息从V199起保存入队时已认证账户的内部ID,普通Web续跑同时携带当前会话工作区;受管工具执行时仍重新校验账户、归属和当前要求。旧队列项不按用户名补造身份,不能用于受管JSON操作;需要用户重新发送已认证请求。持久Goal工作器消费输入时继续使用原有attempt owner校验,没有转换成免租约的账户路径。 + +恢复执行会收到先核实已有证据、不要重放未知副作用的提示。首次恢复执行若在实际运行前延期,下一次领取仍保留恢复关联;已经执行过后的普通续跑不会因此变成新恢复。 diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java index 7ec2c121..04e95ba0 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java @@ -135,6 +135,11 @@ class GoalJsonHttpRuntimeIntegrationTest { org.mockito.stubbing.Answer script = invocation -> { Prompt prompt = invocation.getArgument(0); int step = calls.getAndIncrement(); + if (recovered && step == (plan ? 1 : 0)) { + assertTrue(prompt.getInstructions().stream().anyMatch(message -> message.getText()!=null + && message.getText().contains("Do not replay side effects whose outcome is unknown")), + "Recovered execution must receive the existing-evidence guidance"); + } if (plan && step == 0) { return new ChatResponse(List.of(new Generation(new AssistantMessage( "{\"needs_planning\":true,\"steps\":[\"Produce, publish, check and complete the managed JSON report\"]}")))); 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 index 2b56121f..7841ba4d 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationSupervisorTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationSupervisorTest.java @@ -59,6 +59,25 @@ class GoalContinuationSupervisorTest { verify(coordinator,times(3)).settle(eq(claimed),isA(SegmentOutcome.Continue.class),eq(now)); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({ + "retry,restart_recovery,previous-attempt,true", + "running,legacy,,true", + "retry,evaluation_unavailable,,false" + }) + void passesPersistedRecoveryContextToRunner(String state, String reason, String parent, boolean recovered) { + candidate = new GoalContinuationStore.Continuation(1L, "conv", state, now, null, null, 1, reason, null, 0); + var attempt = new GoalAttempt("recovery-attempt", 1L, "conv", parent, "continuation", "claimed", "new-lease", + now.plusSeconds(60), null, null, "safe", "claimed", null, null, null, null, now, now); + claimed = new GoalRunCoordinator.ClaimedRun(candidate, goal, attempt, 2); + when(store.due(any(), anyInt())).thenReturn(List.of(candidate)); + when(coordinator.claim(candidate, goal, now)).thenReturn(claimed); + when(coordinator.markRunning(claimed, now)).thenReturn(true); + when(runner.run(eq(claimed), anyString(), anyBoolean())).thenReturn(new SegmentOutcome.Continue("normal")); + supervisor.tick(); + verify(runner).run(eq(claimed), contains("full goal"), eq(recovered)); + } + @Test void configuredConcurrencyLimitsSubmittedSegments() { properties.setMaxConcurrentSegments(1); GoalEntity second = new GoalEntity(); diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalRecoveryServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalRecoveryServiceTest.java index 29e2f5a8..9855fa91 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalRecoveryServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalRecoveryServiceTest.java @@ -78,6 +78,25 @@ class GoalRecoveryServiceTest { assertEquals(queued.id(),inputs.listQueued("conv").getFirst().id()); } + @Test void recoveryContextSurvivesDeferralUntilTheFirstExecutedSegment() { + var old = coordinator.claim(continuations.get(1L), goal, now); + assertTrue(coordinator.markRunning(old, now)); + var recoveryTime = now.plusSeconds(61); + assertEquals(1, recovery.recoverExpired(recoveryTime.atZone(java.time.ZoneId.systemDefault()).toInstant())); + var deferred = coordinator.claim(continuations.get(1L), goal, recoveryTime); + assertEquals(old.attempt().id(), deferred.attempt().parentAttemptId()); + var later = now.plusSeconds(90); + assertTrue(coordinator.settle(deferred, new vip.mate.goal.model.SegmentOutcome.Defer("followup_cooldown", later), recoveryTime)); + var resumed = coordinator.claim(continuations.get(1L), goal, later); + assertEquals(deferred.attempt().id(), resumed.attempt().parentAttemptId(), + "A pre-execution cooldown must not discard the pending recovery context"); + assertTrue(coordinator.markRunning(resumed, later)); + assertTrue(coordinator.checkpoint(resumed, "safe", "provider_started", null, later)); + assertTrue(coordinator.settle(resumed, new vip.mate.goal.model.SegmentOutcome.Continue("unfinished"), later)); + var next = coordinator.claim(continuations.get(1L), goal, continuations.get(1L).nextRunAt()); + assertNull(next.attempt().parentAttemptId(), "Ordinary continuation after execution is not a fresh recovery"); + } + @Test void liveProjectionDoesNotAbortRecoveryOfOtherExpiredAttempts() { var live = coordinator.claim(continuations.get(1L), goal, now); assertTrue(coordinator.markRunning(live, now));