fix(goal): preserve recovery guidance until execution starts

This commit is contained in:
mateaix 2026-09-15 01:11:50 +08:00
parent bd85d88355
commit 69091fe28d
7 changed files with 61 additions and 4 deletions

View File

@ -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));

View File

@ -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);

View File

@ -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.

View File

@ -58,3 +58,5 @@ V198 同样以绝对时间保存调度租约截止。升级时旧租约失效,
内置 shell/code 执行没有与服务宿主做操作系统隔离。选择 JSON 验收不会把这些工具变成沙箱;此协议不能抵抗能访问数据库凭据或文件的宿主代码,环境变量名称过滤和工作区路径检查也不能替代隔离。租约截止从绝对时刻计算,覆盖夏令时回拨;调度显示字段仍使用本地时间戳。
Web排队消息从V199起保存入队时已认证账户的内部ID普通Web续跑同时携带当前会话工作区受管工具执行时仍重新校验账户、归属和当前要求。旧队列项不按用户名补造身份不能用于受管JSON操作需要用户重新发送已认证请求。持久Goal工作器消费输入时继续使用原有attempt owner校验没有转换成免租约的账户路径。
恢复执行会收到先核实已有证据、不要重放未知副作用的提示。首次恢复执行若在实际运行前延期,下一次领取仍保留恢复关联;已经执行过后的普通续跑不会因此变成新恢复。

View File

@ -135,6 +135,11 @@ class GoalJsonHttpRuntimeIntegrationTest {
org.mockito.stubbing.Answer<ChatResponse> 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\"]}"))));

View File

@ -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();

View File

@ -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));