|
|
|
@@ -1305,8 +1305,12 @@ class LeadRolloverTest {
|
|
|
|
|
int rolls = LeadRollover.OUTCOME_HISTORY_CAP + 50;
|
|
|
|
|
String[] tokens = new String[rolls];
|
|
|
|
|
for (int i = 0; i < rolls; i++) {
|
|
|
|
|
LeadRollover.PendingRollover pending = rollover.open(LEAD, "context is full #" + i);
|
|
|
|
|
LeadRollover.RollDecision decision = rollover.confirm(LEAD, pending.token(), true);
|
|
|
|
|
// a distinct terminal per iteration: confirm() single-flights per lead terminal, so
|
|
|
|
|
// reusing LEAD here would refuse every roll after the first instead of building up
|
|
|
|
|
// OUTCOME_HISTORY_CAP+50 simultaneously in-flight entries.
|
|
|
|
|
String terminal = "term_cap_" + i;
|
|
|
|
|
LeadRollover.PendingRollover pending = rollover.open(terminal, "context is full #" + i);
|
|
|
|
|
LeadRollover.RollDecision decision = rollover.confirm(terminal, pending.token(), true);
|
|
|
|
|
assertTrue(decision.accepted(), "roll #" + i + " should have been approved: " + decision.detail());
|
|
|
|
|
tokens[i] = pending.token();
|
|
|
|
|
}
|
|
|
|
@@ -1447,4 +1451,195 @@ class LeadRolloverTest {
|
|
|
|
|
assertTrue(status.detail().contains("HerdrException"), "the detail must name the exception "
|
|
|
|
|
+ "so an operator reading status() has something to act on: " + status.detail());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- fleetd #726 unit 3: confirm() single-flights per lead terminal -----------------------
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
@DisplayName("[SINGLE-FLIGHT 1] a second confirm() for the SAME lead terminal is refused with "
|
|
|
|
|
+ "ROLL_ALREADY_RUNNING while the first roll is still in flight, continuationRunner ran "
|
|
|
|
|
+ "exactly once, and the claim is released once that first roll finishes")
|
|
|
|
|
void secondConfirmForSameTerminalIsRefusedWhileFirstRollIsStillInFlight() throws IOException {
|
|
|
|
|
FakeHerdr herdr = new FakeHerdr(); // default idle — the held roll WOULD complete once run
|
|
|
|
|
Path handover = writeHandover("handover contents");
|
|
|
|
|
AtomicLong clock = new AtomicLong(1_000);
|
|
|
|
|
HoldingRunner runner = new HoldingRunner();
|
|
|
|
|
LeadRollover rollover = newRolloverWithHoldingRunner(herdr, cfg(handover.toString()),
|
|
|
|
|
fixedClock(clock), runner);
|
|
|
|
|
|
|
|
|
|
LeadRollover.PendingRollover first = rollover.open(LEAD, "context is full");
|
|
|
|
|
LeadRollover.RollDecision firstDecision = rollover.confirm(LEAD, first.token(), true);
|
|
|
|
|
assertTrue(firstDecision.accepted(), "expected approval; got: " + firstDecision.reason()
|
|
|
|
|
+ " / " + firstDecision.detail());
|
|
|
|
|
assertEquals(1, runner.heldCount(), "sanity: the first roll is held, not run yet");
|
|
|
|
|
|
|
|
|
|
LeadRollover.PendingRollover second = rollover.open(LEAD, "a second request for the same terminal");
|
|
|
|
|
LeadRollover.RollDecision secondDecision = rollover.confirm(LEAD, second.token(), true);
|
|
|
|
|
|
|
|
|
|
assertFalse(secondDecision.accepted());
|
|
|
|
|
assertEquals(LeadRollover.RefusalReason.ROLL_ALREADY_RUNNING, secondDecision.reason());
|
|
|
|
|
assertTrue(secondDecision.detail().contains(LEAD), "the refusal detail must name the lead "
|
|
|
|
|
+ "terminal: " + secondDecision.detail());
|
|
|
|
|
assertTrue(secondDecision.detail().contains(first.token()), "the refusal detail must name "
|
|
|
|
|
+ "the token holding the claim: " + secondDecision.detail());
|
|
|
|
|
assertEquals(1, runner.heldCount(), "the refused confirm() must never have reached "
|
|
|
|
|
+ "continuationRunner — it ran exactly once, for the first roll only");
|
|
|
|
|
|
|
|
|
|
runner.runNext(); // let the first (and only) held roll finish
|
|
|
|
|
assertEquals(2, promptCallCount(herdr), "continuationRunner ran exactly once: /clear then "
|
|
|
|
|
+ "bootstrapText, for the first roll only");
|
|
|
|
|
|
|
|
|
|
// The claim must have been released once the first roll's continuation finished — a
|
|
|
|
|
// fresh request for the SAME terminal is now approved.
|
|
|
|
|
LeadRollover.PendingRollover third = rollover.open(LEAD, "retry after the first roll finished");
|
|
|
|
|
LeadRollover.RollDecision thirdDecision = rollover.confirm(LEAD, third.token(), true);
|
|
|
|
|
assertTrue(thirdDecision.accepted(), "the claim must have been released once the first "
|
|
|
|
|
+ "roll finished: " + thirdDecision.reason() + " / " + thirdDecision.detail());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
@DisplayName("[SINGLE-FLIGHT 2] two DIFFERENT lead terminals confirm without either being "
|
|
|
|
|
+ "refused, and the continuation runs once per terminal")
|
|
|
|
|
void differentLeadTerminalsConfirmIndependently() throws IOException {
|
|
|
|
|
FakeHerdr herdr = new FakeHerdr(); // default idle — both rolls complete
|
|
|
|
|
Path handover = writeHandover("handover contents");
|
|
|
|
|
AtomicLong clock = new AtomicLong(1_000);
|
|
|
|
|
LeadRollover rollover = newRollover(herdr, cfg(handover.toString()), fixedClock(clock));
|
|
|
|
|
|
|
|
|
|
LeadRollover.PendingRollover a = rollover.open(LEAD, "context is full");
|
|
|
|
|
LeadRollover.PendingRollover b = rollover.open(OTHER_LEAD, "context is full too");
|
|
|
|
|
|
|
|
|
|
LeadRollover.RollDecision aDecision = rollover.confirm(LEAD, a.token(), true);
|
|
|
|
|
LeadRollover.RollDecision bDecision = rollover.confirm(OTHER_LEAD, b.token(), true);
|
|
|
|
|
|
|
|
|
|
assertTrue(aDecision.accepted(), "expected approval; got: " + aDecision.reason() + " / " + aDecision.detail());
|
|
|
|
|
assertTrue(bDecision.accepted(), "expected approval; got: " + bDecision.reason() + " / " + bDecision.detail());
|
|
|
|
|
assertEquals(4, promptCallCount(herdr), "both rolls ran to completion — /clear and "
|
|
|
|
|
+ "bootstrapText, once per terminal");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
@DisplayName("[SINGLE-FLIGHT 3] a confirm() refused for OPERATOR_NOT_CONFIRMED does not take "
|
|
|
|
|
+ "the single-flight claim — a later valid confirm on the same terminal is still approved")
|
|
|
|
|
void refusedConfirmDoesNotTakeTheClaim() throws IOException {
|
|
|
|
|
FakeHerdr herdr = new FakeHerdr();
|
|
|
|
|
Path handover = writeHandover("handover contents");
|
|
|
|
|
AtomicLong clock = new AtomicLong(1_000);
|
|
|
|
|
LeadRollover rollover = newRollover(herdr, cfg(handover.toString(), true), fixedClock(clock));
|
|
|
|
|
|
|
|
|
|
LeadRollover.PendingRollover pending = rollover.open(LEAD, "context is full");
|
|
|
|
|
LeadRollover.RollDecision refused = rollover.confirm(LEAD, pending.token(), false);
|
|
|
|
|
assertFalse(refused.accepted());
|
|
|
|
|
assertEquals(LeadRollover.RefusalReason.OPERATOR_NOT_CONFIRMED, refused.reason());
|
|
|
|
|
|
|
|
|
|
// the same token stays pending after a gate refusal — retrying it with operator
|
|
|
|
|
// confirmation must succeed, proving the earlier refusal never took the claim.
|
|
|
|
|
LeadRollover.RollDecision approved = rollover.confirm(LEAD, pending.token(), true);
|
|
|
|
|
assertTrue(approved.accepted(), "a confirm() refused for an existing gate must never have "
|
|
|
|
|
+ "taken the single-flight claim: " + approved.reason() + " / " + approved.detail());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
@DisplayName("[SINGLE-FLIGHT 4] a roll whose continuation throws a RuntimeException still "
|
|
|
|
|
+ "releases the claim — a fresh confirm() on that terminal is approved afterwards")
|
|
|
|
|
void throwingContinuationStillReleasesTheClaim() throws IOException {
|
|
|
|
|
FakeHerdr fake = new FakeHerdr(); // default idle — the turn-settle wait passes immediately
|
|
|
|
|
HerdrClient throwsOnClear = new HerdrClient() {
|
|
|
|
|
@Override
|
|
|
|
|
public JsonNode call(String method, Object params) throws HerdrException {
|
|
|
|
|
if ("agent.prompt".equals(method) && String.valueOf(params).contains("/clear")) {
|
|
|
|
|
throw new HerdrException("simulated herdr transport failure sending /clear");
|
|
|
|
|
}
|
|
|
|
|
return fake.call(method, params);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
public void close() {
|
|
|
|
|
fake.close();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
Path handover = writeHandover("handover contents");
|
|
|
|
|
AtomicLong clock = new AtomicLong(1_000);
|
|
|
|
|
LeadRollover rollover = newRollover(throwsOnClear, cfg(handover.toString()), fixedClock(clock));
|
|
|
|
|
|
|
|
|
|
LeadRollover.PendingRollover first = rollover.open(LEAD, "context is full");
|
|
|
|
|
LeadRollover.RollDecision decision = rollover.confirm(LEAD, first.token(), true);
|
|
|
|
|
assertTrue(decision.accepted(), "every synchronous gate passes; the throw happens only "
|
|
|
|
|
+ "inside the deferred continuation, which this test's synchronous runner has "
|
|
|
|
|
+ "already run to completion by the time confirm() returns");
|
|
|
|
|
|
|
|
|
|
LeadRollover.RollStatus status = rollover.status(first.token());
|
|
|
|
|
assertEquals(LeadRollover.RollState.FAILED, status.state(), "sanity: the continuation threw "
|
|
|
|
|
+ "and left a terminal FAILED outcome: " + status.detail());
|
|
|
|
|
|
|
|
|
|
LeadRollover.PendingRollover second = rollover.open(LEAD, "retry after the failure");
|
|
|
|
|
LeadRollover.RollDecision retryDecision = rollover.confirm(LEAD, second.token(), true);
|
|
|
|
|
assertTrue(retryDecision.accepted(), "the claim must have been released even though the "
|
|
|
|
|
+ "continuation threw — a release only on the success path would leave this lead "
|
|
|
|
|
+ "terminal unrollable forever after one failure: " + retryDecision.reason() + " / "
|
|
|
|
|
+ retryDecision.detail());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
@DisplayName("[SINGLE-FLIGHT 5] cancel(token) after a successful confirm(token) returns false "
|
|
|
|
|
+ "and does not release the claim held by that roll")
|
|
|
|
|
void cancelAfterConfirmDoesNotReleaseTheClaim() throws IOException {
|
|
|
|
|
FakeHerdr herdr = new FakeHerdr();
|
|
|
|
|
Path handover = writeHandover("handover contents");
|
|
|
|
|
AtomicLong clock = new AtomicLong(1_000);
|
|
|
|
|
HoldingRunner runner = new HoldingRunner();
|
|
|
|
|
LeadRollover rollover = newRolloverWithHoldingRunner(herdr, cfg(handover.toString()),
|
|
|
|
|
fixedClock(clock), runner);
|
|
|
|
|
|
|
|
|
|
LeadRollover.PendingRollover pending = rollover.open(LEAD, "context is full");
|
|
|
|
|
LeadRollover.RollDecision decision = rollover.confirm(LEAD, pending.token(), true);
|
|
|
|
|
assertTrue(decision.accepted(), "expected approval; got: " + decision.reason() + " / " + decision.detail());
|
|
|
|
|
|
|
|
|
|
assertFalse(rollover.cancel(pending.token()), "confirm() already removed the token from "
|
|
|
|
|
+ "pending, so cancel() must find nothing for it");
|
|
|
|
|
|
|
|
|
|
LeadRollover.PendingRollover another = rollover.open(LEAD,
|
|
|
|
|
"a second request while the first roll is still held");
|
|
|
|
|
LeadRollover.RollDecision anotherDecision = rollover.confirm(LEAD, another.token(), true);
|
|
|
|
|
assertFalse(anotherDecision.accepted(), "cancel() on the already-confirmed token must not "
|
|
|
|
|
+ "have released the claim held by that roll");
|
|
|
|
|
assertEquals(LeadRollover.RefusalReason.ROLL_ALREADY_RUNNING, anotherDecision.reason());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
@DisplayName("[SINGLE-FLIGHT 6] a continuationRunner that rejects the hand-off still releases "
|
|
|
|
|
+ "the claim and leaves a terminal FAILED outcome, not a stuck IN_PROGRESS")
|
|
|
|
|
void continuationRunnerThatRejectsTheHandOffStillReleasesTheClaim() throws IOException {
|
|
|
|
|
FakeHerdr herdr = new FakeHerdr();
|
|
|
|
|
Path handover = writeHandover("handover contents");
|
|
|
|
|
AtomicLong clock = new AtomicLong(1_000);
|
|
|
|
|
AgentControl agents = new AgentControl(herdr);
|
|
|
|
|
// A continuationRunner that rejects every hand-off, standing in for e.g. a bounded
|
|
|
|
|
// executor's RejectedExecutionException — the continuation never runs, so runRollover's
|
|
|
|
|
// own finally never gets a chance to release the claim either.
|
|
|
|
|
LeadRollover rollover = new LeadRollover(agents, () -> cfg(handover.toString()), _ -> null,
|
|
|
|
|
fixedClock(clock), () -> { },
|
|
|
|
|
r -> { throw new RuntimeException("simulated continuationRunner rejection"); });
|
|
|
|
|
|
|
|
|
|
LeadRollover.PendingRollover pending = rollover.open(LEAD, "context is full");
|
|
|
|
|
LeadRollover.RollDecision decision = rollover.confirm(LEAD, pending.token(), true);
|
|
|
|
|
|
|
|
|
|
assertTrue(decision.accepted(), "every gate passed before the hand-off itself rejected — "
|
|
|
|
|
+ "confirm() treats that rejection the same way it treats a throw INSIDE the "
|
|
|
|
|
+ "continuation: logged and surfaced only through status(), never as a refusal here: "
|
|
|
|
|
+ decision.reason() + " / " + decision.detail());
|
|
|
|
|
|
|
|
|
|
LeadRollover.RollStatus status = rollover.status(pending.token());
|
|
|
|
|
assertEquals(LeadRollover.RollState.FAILED, status.state(), "a rejected hand-off must leave "
|
|
|
|
|
+ "a TERMINAL outcome, never a stuck IN_PROGRESS — status() would otherwise have no "
|
|
|
|
|
+ "way to tell a roll that never started from one still genuinely running: got "
|
|
|
|
|
+ status.state() + " / " + status.detail());
|
|
|
|
|
assertNotEquals(LeadRollover.RollState.IN_PROGRESS, status.state());
|
|
|
|
|
|
|
|
|
|
LeadRollover.PendingRollover retry = rollover.open(LEAD, "retry after the rejected hand-off");
|
|
|
|
|
LeadRollover.RollDecision retryDecision = rollover.confirm(LEAD, retry.token(), true);
|
|
|
|
|
assertTrue(retryDecision.accepted(), "the claim must have been released even though "
|
|
|
|
|
+ "continuationRunner itself threw before the continuation ever ran — otherwise "
|
|
|
|
|
+ "this lead terminal could never be rolled again: " + retryDecision.reason() + " / "
|
|
|
|
|
+ retryDecision.detail());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|