mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 03:33:43 +08:00
fix(evidence): preserve failures across command observations
This commit is contained in:
parent
ea1a28399c
commit
02f31fdaf6
@ -20,6 +20,7 @@ public final class ExecutionObservationSink {
|
||||
private final List<EvidenceObservation> observations = new ArrayList<>();
|
||||
private AttemptState state = AttemptState.SUCCEEDED;
|
||||
private boolean sealed;
|
||||
private long commandCount;
|
||||
|
||||
public ExecutionObservationSink(boolean metadataOnly) { this(metadataOnly, 32); }
|
||||
|
||||
@ -52,17 +53,29 @@ public final class ExecutionObservationSink {
|
||||
|
||||
public synchronized void command(Integer exitCode, boolean timedOut, boolean cancelled, boolean blocked, String workingDirectory) {
|
||||
if (sealed) return;
|
||||
state = cancelled ? AttemptState.CANCELLED : timedOut || exitCode == null ? AttemptState.UNKNOWN
|
||||
: blocked ? AttemptState.BLOCKED : exitCode == 0 ? AttemptState.SUCCEEDED : AttemptState.FAILED;
|
||||
EvidenceResult result = state == AttemptState.SUCCEEDED ? EvidenceResult.OBSERVED
|
||||
: state == AttemptState.UNKNOWN || state == AttemptState.CANCELLED
|
||||
AttemptState commandState = cancelled ? AttemptState.CANCELLED : blocked ? AttemptState.BLOCKED
|
||||
: timedOut || exitCode == null ? AttemptState.UNKNOWN
|
||||
: exitCode == 0 ? AttemptState.SUCCEEDED : AttemptState.FAILED;
|
||||
state = commandCount == 0 ? commandState : aggregate(state, commandState);
|
||||
commandCount++;
|
||||
EvidenceResult result = commandState == AttemptState.SUCCEEDED ? EvidenceResult.OBSERVED
|
||||
: commandState == AttemptState.UNKNOWN || commandState == AttemptState.CANCELLED
|
||||
? EvidenceResult.UNKNOWN : EvidenceResult.FAIL;
|
||||
append(new EvidenceObservation("command", EvidenceKind.COMMAND_EXIT, result,
|
||||
append(new EvidenceObservation(commandCount == 1 ? "command" : "command:" + commandCount, EvidenceKind.COMMAND_EXIT, result,
|
||||
SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null, workingDirectory,
|
||||
null, null, "exit=" + exitCode + "; timedOut=" + timedOut
|
||||
+ "; cancelled=" + cancelled + "; blocked=" + blocked, null, null, null));
|
||||
}
|
||||
|
||||
private static AttemptState aggregate(AttemptState prior, AttemptState current) {
|
||||
if (prior == AttemptState.CANCELLED || current == AttemptState.CANCELLED) return AttemptState.CANCELLED;
|
||||
if (prior == AttemptState.UNKNOWN || current == AttemptState.UNKNOWN) return AttemptState.UNKNOWN;
|
||||
if (prior == AttemptState.FAILED || current == AttemptState.FAILED) return AttemptState.FAILED;
|
||||
if (prior == current) return prior;
|
||||
// A blocked command mixed with actual execution cannot certify no effects.
|
||||
return AttemptState.UNKNOWN;
|
||||
}
|
||||
|
||||
/** Called only after file bytes and owner metadata have survived durable read-back. */
|
||||
public synchronized void artifact(String id, String digest, long length, String mimeType, Instant expiresAt) {
|
||||
append(new EvidenceObservation("artifact:" + id, EvidenceKind.ARTIFACT_SNAPSHOT,
|
||||
|
||||
@ -96,6 +96,55 @@ class ExecutionEvidenceRecorderTest {
|
||||
assertThrows(IllegalStateException.class, () -> new ExecutionEvidenceRecorder(store, identities, properties, new SimpleMeterRegistry()));
|
||||
}
|
||||
|
||||
@Test void multipleCommandsPreserveFailureAndDistinctObservations() {
|
||||
when(callback.call(anyString(), any())).thenAnswer(call -> {
|
||||
var sink = ExecutionObservationSink.from(call.getArgument(1));
|
||||
sink.command(7, false, false, false);
|
||||
sink.command(0, false, false, false);
|
||||
return "last command succeeded";
|
||||
});
|
||||
assertEquals("last command succeeded", invoke());
|
||||
verify(store).finish(eq(1L), eq("fence"), eq(AttemptState.FAILED), eq(EffectOutcome.UNCERTAIN),
|
||||
argThat(rows -> rows.size() == 3 && rows.getFirst().result() == EvidenceResult.FAIL
|
||||
&& rows.get(1).result() == EvidenceResult.OBSERVED
|
||||
&& rows.getLast().result() == EvidenceResult.FAIL));
|
||||
}
|
||||
|
||||
@Test void blockedAndExecutedCommandsCannotClaimNoSideEffects() {
|
||||
when(callback.call(anyString(), any())).thenAnswer(call -> {
|
||||
var sink = ExecutionObservationSink.from(call.getArgument(1));
|
||||
sink.command(null, false, false, true);
|
||||
sink.command(0, false, false, false);
|
||||
return "partial execution";
|
||||
});
|
||||
assertEquals("partial execution", invoke());
|
||||
verify(store).finish(eq(1L), eq("fence"), eq(AttemptState.UNKNOWN), eq(EffectOutcome.UNCERTAIN), anyList());
|
||||
}
|
||||
|
||||
@Test void observationLimitAndSealDoNotEraseFailure() {
|
||||
var sink = new ExecutionObservationSink(false, 1);
|
||||
sink.command(0, false, false, false);
|
||||
sink.command(7, false, false, false);
|
||||
assertEquals(1, sink.observations().size());
|
||||
assertEquals(AttemptState.FAILED, sink.state());
|
||||
sink.seal();
|
||||
sink.command(0, false, false, false);
|
||||
assertEquals(AttemptState.FAILED, sink.state());
|
||||
assertEquals(1, sink.observations().size());
|
||||
}
|
||||
|
||||
@Test void laterSuccessPreservesTimeoutAndCancellationEvenForDirectResults() {
|
||||
var timeout = new ExecutionObservationSink(true);
|
||||
timeout.command(null, true, false, false);
|
||||
timeout.command(0, false, false, false);
|
||||
assertEquals(AttemptState.UNKNOWN, timeout.state());
|
||||
assertTrue(timeout.observations().isEmpty());
|
||||
var cancelled = new ExecutionObservationSink(false);
|
||||
cancelled.command(null, false, true, false);
|
||||
cancelled.command(0, false, false, false);
|
||||
assertEquals(AttemptState.CANCELLED, cancelled.state());
|
||||
}
|
||||
|
||||
private String invoke() {
|
||||
return recorder.invoke(callback, "{}", ChatOrigin.web("conv", "owner", 1L, null).toToolContext(), "invocation", "provider-id");
|
||||
}
|
||||
|
||||
@ -105,6 +105,18 @@ class TrustedExecutionObservationTest {
|
||||
assertArrayEquals(new GeneratedFileCache(root.resolve("cache")).get(id).orElseThrow().bytes(), downloaded);
|
||||
}
|
||||
|
||||
@Test void laterSuccessfulProcessCannotEraseEarlierFailureInSameInvocation() {
|
||||
var sink = new ExecutionObservationSink(false);
|
||||
var ctx = context(sink);
|
||||
shell().execute_shell_command("exit 7", 5, ctx);
|
||||
shell().execute_shell_command("exit 0", 5, ctx);
|
||||
assertEquals(AttemptState.FAILED, sink.state());
|
||||
assertEquals(2, sink.observations().size());
|
||||
assertEquals(EvidenceResult.FAIL, sink.observations().getFirst().result());
|
||||
assertEquals(EvidenceResult.OBSERVED, sink.observations().getLast().result());
|
||||
assertNotEquals(sink.observations().getFirst().sourceKey(), sink.observations().getLast().sourceKey());
|
||||
}
|
||||
|
||||
private ShellExecuteTool shell() {
|
||||
return new ShellExecuteTool(mock(I18nService.class), new GeneratedFileCache(root.resolve("cache")));
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user