From 02f31fdaf6a2aa3fd65f575d5c6ea13ba6061349 Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Mon, 14 Sep 2026 00:24:52 +0800 Subject: [PATCH] fix(evidence): preserve failures across command observations --- .../service/ExecutionObservationSink.java | 23 +++++++-- .../ExecutionEvidenceRecorderTest.java | 49 +++++++++++++++++++ .../TrustedExecutionObservationTest.java | 12 +++++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionObservationSink.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionObservationSink.java index 54ecd5da..f4891273 100644 --- a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionObservationSink.java +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionObservationSink.java @@ -20,6 +20,7 @@ public final class ExecutionObservationSink { private final List 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, diff --git a/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceRecorderTest.java b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceRecorderTest.java index a09466de..cbcf41da 100644 --- a/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceRecorderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceRecorderTest.java @@ -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"); } diff --git a/mateclaw-server/src/test/java/vip/mate/execution/evidence/TrustedExecutionObservationTest.java b/mateclaw-server/src/test/java/vip/mate/execution/evidence/TrustedExecutionObservationTest.java index afec4def..a5fad8da 100644 --- a/mateclaw-server/src/test/java/vip/mate/execution/evidence/TrustedExecutionObservationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/execution/evidence/TrustedExecutionObservationTest.java @@ -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"))); }