fix(evidence): preserve failures across command observations

This commit is contained in:
mateaix 2026-09-14 00:24:52 +08:00
parent ea1a28399c
commit 02f31fdaf6
3 changed files with 79 additions and 5 deletions

View File

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

View File

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

View File

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