mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
fix(evidence): seal observation state and rows atomically
This commit is contained in:
parent
6efae9e71c
commit
9f89db8262
@ -81,7 +81,7 @@ public class ExecutionEvidenceRecorder {
|
|||||||
observedContext = sink.attach(new ToolContext(values));
|
observedContext = sink.attach(new ToolContext(values));
|
||||||
}
|
}
|
||||||
String result = callback.call(arguments, observedContext);
|
String result = callback.call(arguments, observedContext);
|
||||||
finish(attempt, sink, sink.state(), "Tool callback returned");
|
finish(attempt, sink, null, "Tool callback returned");
|
||||||
return result;
|
return result;
|
||||||
} catch (RuntimeException | Error error) {
|
} catch (RuntimeException | Error error) {
|
||||||
AttemptState state = error instanceof CancellationException || Thread.currentThread().isInterrupted()
|
AttemptState state = error instanceof CancellationException || Thread.currentThread().isInterrupted()
|
||||||
@ -91,8 +91,9 @@ public class ExecutionEvidenceRecorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void finish(ExecutionAttempt attempt, ExecutionObservationSink sink, AttemptState state, String summary) {
|
private void finish(ExecutionAttempt attempt, ExecutionObservationSink sink, AttemptState overrideState, String summary) {
|
||||||
sink.seal();
|
var captured = sink.sealAndSnapshot();
|
||||||
|
AttemptState state = overrideState != null ? overrideState : captured.state();
|
||||||
if (attempt == null) return;
|
if (attempt == null) return;
|
||||||
long began = System.nanoTime();
|
long began = System.nanoTime();
|
||||||
try {
|
try {
|
||||||
@ -100,7 +101,7 @@ public class ExecutionEvidenceRecorder {
|
|||||||
failure("owner_lost");
|
failure("owner_lost");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var observations = new ArrayList<>(sink.observations());
|
var observations = new ArrayList<>(captured.observations());
|
||||||
observations.add(new EvidenceObservation("callback", EvidenceKind.TOOL_RETURNED,
|
observations.add(new EvidenceObservation("callback", EvidenceKind.TOOL_RETURNED,
|
||||||
state == AttemptState.SUCCEEDED ? EvidenceResult.OBSERVED
|
state == AttemptState.SUCCEEDED ? EvidenceResult.OBSERVED
|
||||||
: state == AttemptState.UNKNOWN || state == AttemptState.CANCELLED
|
: state == AttemptState.UNKNOWN || state == AttemptState.CANCELLED
|
||||||
|
|||||||
@ -46,6 +46,16 @@ public final class ExecutionObservationSink {
|
|||||||
public synchronized List<EvidenceObservation> observations() { return List.copyOf(observations); }
|
public synchronized List<EvidenceObservation> observations() { return List.copyOf(observations); }
|
||||||
public synchronized void seal() { sealed = true; }
|
public synchronized void seal() { sealed = true; }
|
||||||
|
|
||||||
|
public record Snapshot(AttemptState state, List<EvidenceObservation> observations) {
|
||||||
|
public Snapshot { observations = List.copyOf(observations); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** State and rows share one linearization point; later observations are ignored. */
|
||||||
|
public synchronized Snapshot sealAndSnapshot() {
|
||||||
|
sealed = true;
|
||||||
|
return new Snapshot(state, observations);
|
||||||
|
}
|
||||||
|
|
||||||
/** Called by the process adapter, never by parsing a tool's returned text. */
|
/** Called by the process adapter, never by parsing a tool's returned text. */
|
||||||
public void command(Integer exitCode, boolean timedOut, boolean cancelled, boolean blocked) {
|
public void command(Integer exitCode, boolean timedOut, boolean cancelled, boolean blocked) {
|
||||||
command(exitCode, timedOut, cancelled, blocked, null);
|
command(exitCode, timedOut, cancelled, blocked, null);
|
||||||
|
|||||||
@ -145,6 +145,47 @@ class ExecutionEvidenceRecorderTest {
|
|||||||
assertEquals(AttemptState.CANCELLED, cancelled.state());
|
assertEquals(AttemptState.CANCELLED, cancelled.state());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void observationArrivingImmediatelyBeforeSealCannotDisagreeWithStoredState() throws Exception {
|
||||||
|
var actualSink = new ExecutionObservationSink(false);
|
||||||
|
when(callback.call(anyString(), any())).thenReturn("callback returned");
|
||||||
|
// Deterministically inject the legal interleaving: an observer arrives
|
||||||
|
// immediately before sealing, after the old recorder read state().
|
||||||
|
try (var constructed = mockConstruction(ExecutionObservationSink.class, withSettings().defaultAnswer(call -> {
|
||||||
|
if (call.getMethod().getName().startsWith("seal")) {
|
||||||
|
actualSink.command(7, false, false, false);
|
||||||
|
}
|
||||||
|
return call.getMethod().invoke(actualSink, call.getArguments());
|
||||||
|
}))) {
|
||||||
|
assertEquals("callback returned", invoke());
|
||||||
|
assertEquals(1, constructed.constructed().size());
|
||||||
|
verify(store).finish(eq(1L), eq("fence"), eq(AttemptState.FAILED), eq(EffectOutcome.UNCERTAIN),
|
||||||
|
argThat(rows -> rows.size() == 2 && rows.stream().allMatch(row -> row.result() == EvidenceResult.FAIL)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void sealedSnapshotCannotBeChangedByLateObserversOrItsReader() {
|
||||||
|
var sink = new ExecutionObservationSink(false);
|
||||||
|
sink.command(7, false, false, false);
|
||||||
|
var captured = sink.sealAndSnapshot();
|
||||||
|
sink.command(0, false, false, false);
|
||||||
|
sink.artifact("late", "digest", 1, "text/plain", Instant.now());
|
||||||
|
assertEquals(AttemptState.FAILED, captured.state());
|
||||||
|
assertEquals(1, captured.observations().size());
|
||||||
|
assertEquals(captured, sink.sealAndSnapshot());
|
||||||
|
assertThrows(UnsupportedOperationException.class, () -> captured.observations().clear());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void callbackCancellationOverridesSuccessfulCommandSnapshot() {
|
||||||
|
when(callback.call(anyString(), any())).thenAnswer(call -> {
|
||||||
|
ExecutionObservationSink.from(call.getArgument(1)).command(0, false, false, false);
|
||||||
|
throw new java.util.concurrent.CancellationException("cancelled");
|
||||||
|
});
|
||||||
|
assertThrows(java.util.concurrent.CancellationException.class, this::invoke);
|
||||||
|
verify(store).finish(eq(1L), eq("fence"), eq(AttemptState.CANCELLED), eq(EffectOutcome.UNCERTAIN),
|
||||||
|
argThat(rows -> rows.size() == 2 && rows.getFirst().result() == EvidenceResult.OBSERVED
|
||||||
|
&& rows.getLast().result() == EvidenceResult.UNKNOWN));
|
||||||
|
}
|
||||||
|
|
||||||
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");
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user