mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +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 final List<EvidenceObservation> observations = new ArrayList<>();
|
||||||
private AttemptState state = AttemptState.SUCCEEDED;
|
private AttemptState state = AttemptState.SUCCEEDED;
|
||||||
private boolean sealed;
|
private boolean sealed;
|
||||||
|
private long commandCount;
|
||||||
|
|
||||||
public ExecutionObservationSink(boolean metadataOnly) { this(metadataOnly, 32); }
|
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) {
|
public synchronized void command(Integer exitCode, boolean timedOut, boolean cancelled, boolean blocked, String workingDirectory) {
|
||||||
if (sealed) return;
|
if (sealed) return;
|
||||||
state = cancelled ? AttemptState.CANCELLED : timedOut || exitCode == null ? AttemptState.UNKNOWN
|
AttemptState commandState = cancelled ? AttemptState.CANCELLED : blocked ? AttemptState.BLOCKED
|
||||||
: blocked ? AttemptState.BLOCKED : exitCode == 0 ? AttemptState.SUCCEEDED : AttemptState.FAILED;
|
: timedOut || exitCode == null ? AttemptState.UNKNOWN
|
||||||
EvidenceResult result = state == AttemptState.SUCCEEDED ? EvidenceResult.OBSERVED
|
: exitCode == 0 ? AttemptState.SUCCEEDED : AttemptState.FAILED;
|
||||||
: state == AttemptState.UNKNOWN || state == AttemptState.CANCELLED
|
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;
|
? 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,
|
SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null, workingDirectory,
|
||||||
null, null, "exit=" + exitCode + "; timedOut=" + timedOut
|
null, null, "exit=" + exitCode + "; timedOut=" + timedOut
|
||||||
+ "; cancelled=" + cancelled + "; blocked=" + blocked, null, null, null));
|
+ "; 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. */
|
/** 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) {
|
public synchronized void artifact(String id, String digest, long length, String mimeType, Instant expiresAt) {
|
||||||
append(new EvidenceObservation("artifact:" + id, EvidenceKind.ARTIFACT_SNAPSHOT,
|
append(new EvidenceObservation("artifact:" + id, EvidenceKind.ARTIFACT_SNAPSHOT,
|
||||||
|
|||||||
@ -96,6 +96,55 @@ class ExecutionEvidenceRecorderTest {
|
|||||||
assertThrows(IllegalStateException.class, () -> new ExecutionEvidenceRecorder(store, identities, properties, new SimpleMeterRegistry()));
|
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() {
|
private String invoke() {
|
||||||
return recorder.invoke(callback, "{}", ChatOrigin.web("conv", "owner", 1L, null).toToolContext(), "invocation", "provider-id");
|
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);
|
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() {
|
private ShellExecuteTool shell() {
|
||||||
return new ShellExecuteTool(mock(I18nService.class), new GeneratedFileCache(root.resolve("cache")));
|
return new ShellExecuteTool(mock(I18nService.class), new GeneratedFileCache(root.resolve("cache")));
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user