diff --git a/fleetd/src/main/java/dev/ltms/fleet/inject/Injector.java b/fleetd/src/main/java/dev/ltms/fleet/inject/Injector.java index bf79d8a..2bc3b99 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/inject/Injector.java +++ b/fleetd/src/main/java/dev/ltms/fleet/inject/Injector.java @@ -2,6 +2,7 @@ package dev.ltms.fleet.inject; import dev.ltms.fleet.herdr.AgentControl; import dev.ltms.fleet.herdr.AgentStatus; +import dev.ltms.fleet.herdr.HerdrException; import dev.ltms.fleet.herdr.HerdrRouter; import dev.ltms.fleet.msg.TurnToken; import org.slf4j.Logger; @@ -46,6 +47,17 @@ import java.util.stream.Collectors; * turn — the pickup-grace path (a turn too fast to sample) unwedges the queue but does not fire * completion, since without a sampled {@code working} there is no trustworthy "the worker just * finished the task" signal to act on. + * + *
Delivery honesty (fleetd #551). The one external send call
+ * ({@link AgentControl#send}) is irreversible, and its own response can still fail after the text
+ * has already reached the worker's pane — herdr replied (or may have), so the request was already
+ * processed, but the reply itself then failed to parse or carried an error. A queued entry is
+ * polled off the queue and marked {@code ATTEMPTED} before that call is made, not after,
+ * so a failure from the call itself can never be recorded as a confident {@code NOT_DELIVERED} for
+ * text that may already be sitting in the pane. {@code NOT_DELIVERED} stays reserved for the cases
+ * where nothing was ever attempted — the readiness grace expiring, {@link #drop}, or a herdr
+ * {@code *_not_found} error, which the rest of this codebase already treats as a confirmed absence
+ * rather than a merely inconclusive failure (see {@code StatusPoller}, {@code AgentControl}).
*/
public final class Injector {
@@ -217,8 +229,23 @@ public final class Injector {
/** The result of trying to remove an undelivered message from the injector. */
public enum Cancellation {
+ /** The message was still queued and this call removed it; the target saw nothing. */
CANCELLED,
+ /** The message reached the worker's pane and is confirmed delivered. */
DELIVERED,
+ /**
+ * fleetd #551: the injector called {@link AgentControl#send} for this message and that call
+ * threw before its outcome was known — the text may or may not have reached the pane. A
+ * caller reporting this to an operator must say "uncertain", not "definitely not
+ * delivered": reading it as a confident negative invites a resend of text that may already
+ * be sitting in the pane (a double delivery), which is worse than the ambiguity itself.
+ */
+ ATTEMPTED,
+ /**
+ * The message never reached the worker's pane — nothing was ever attempted for it (the
+ * readiness grace expired, the target was dropped, or send failed with a herdr
+ * {@code *_not_found} error, which is a confirmed absence, not merely inconclusive).
+ */
NOT_DELIVERED
}
@@ -241,7 +268,29 @@ public final class Injector {
/** A pending message and the future that completes when it has been delivered. */
private static final class Pending {
- enum State { QUEUED, DELIVERED, NOT_DELIVERED, CANCELLED }
+ enum State {
+ /** Still in the target's queue, not yet attempted. */
+ QUEUED,
+ /**
+ * fleetd #551: {@link AgentControl#send} has been called for this entry and its outcome
+ * is not yet known — recorded BEFORE the call (see {@link #onStatus}), so a Throwable
+ * from send() can never leave the entry either still QUEUED or falsely marked
+ * NOT_DELIVERED. Upgraded to DELIVERED on success; left as ATTEMPTED on an ordinary
+ * failure, since reaching the catch does not prove the text never reached the pane.
+ */
+ ATTEMPTED,
+ /** {@code send()} returned normally: the text is confirmed to have reached the pane. */
+ DELIVERED,
+ /**
+ * Confirmed — not merely inconclusive — that nothing was ever sent for this entry: the
+ * worker never became ready ({@link #onStatus}'s readiness-grace expiry), the target was
+ * dropped ({@link #drop}), or send failed with a herdr {@code *_not_found} error. Never
+ * written for an entry whose send() outcome is unknown; see ATTEMPTED.
+ */
+ NOT_DELIVERED,
+ /** Removed from the queue by {@link #cancel} before it was ever attempted. */
+ CANCELLED
+ }
final String target;
final String text;
@@ -333,7 +382,14 @@ public final class Injector {
}
private static Cancellation cancellationOf(Pending p) {
- return p.state == Pending.State.DELIVERED ? Cancellation.DELIVERED : Cancellation.NOT_DELIVERED;
+ // fleetd #551 (comment 17037): a two-way split on a now-three-way question folded ATTEMPTED
+ // into NOT_DELIVERED with no compiler error and no failing test — the exact defect this
+ // ticket exists to fix, one layer up. ATTEMPTED gets its own answer instead.
+ return switch (p.state) {
+ case DELIVERED -> Cancellation.DELIVERED;
+ case ATTEMPTED -> Cancellation.ATTEMPTED;
+ case QUEUED, NOT_DELIVERED, CANCELLED -> Cancellation.NOT_DELIVERED;
+ };
}
private static boolean isQuiescent(Target t) {
@@ -426,9 +482,18 @@ public final class Injector {
if (p != null && ready.test(target)) {
t.notReadySincePoll = 0;
t.notReadySinceMillis = 0;
+ // fleetd #551: poll and record BEFORE the irreversible send, not after.
+ // The entry comes off the queue and its state is set to ATTEMPTED here,
+ // unconditionally — so a Throwable escaping the send call below (caught or
+ // not) can never leave the entry QUEUED at the head of t.queue (the fleetd
+ // #546 hazard, since peek() alone would let the next onStatus round re-enter
+ // this block and send the same text again), and no path can write a
+ // confident DELIVERED or NOT_DELIVERED before we actually know which one
+ // happened.
+ t.queue.poll();
+ p.state = Pending.State.ATTEMPTED;
try {
agentsFor(target).send(target, p.text());
- t.queue.poll();
p.state = Pending.State.DELIVERED;
t.awaitingPickup = true;
t.awaitingCompletion = true;
@@ -436,15 +501,24 @@ public final class Injector {
t.injectableSincePickup = 0;
sent = p;
} catch (Throwable e) {
- // Delivery failed at herdr; drop the poisoned message and surface it
- // rather than blocking the queue behind it. Catches Throwable, not just
- // RuntimeException: fleetd #546 — an Error escaping this send (e.g. a
- // NoClassDefFoundError, see #413) would otherwise leave the entry QUEUED
- // at the head of t.queue. Line :378 peeks rather than polls, so the next
- // onStatus round would re-enter this try and send the same text again,
- // typing the same brief into the member's pane a second time.
- t.queue.poll();
- p.state = Pending.State.NOT_DELIVERED;
+ // fleetd #551: leave p.state == ATTEMPTED (recorded above, before the
+ // call) rather than downgrading it to NOT_DELIVERED here — reaching this
+ // catch does not prove the text never reached the pane. Three of the
+ // four HerdrException throw sites in HerdrCodec fire only after herdr
+ // has already replied (so it processed the request), and the fourth (a
+ // transport IOException) leaves it genuinely unknown whether herdr even
+ // received the bytes — see #551 comment 16867. The one exception is a
+ // herdr `*_not_found` error: that family is already read as "definitely
+ // absent, not merely inconclusive" everywhere else in this codebase
+ // (StatusPoller, AgentControl's own retry, WorkspaceControl,
+ // HerdrPeerLauncher, FleetApp, ReplyPushLoop) because it means the
+ // target pane/agent does not exist at all, so nothing could have been
+ // pasted anywhere — #551 keeps the new state consistent with that
+ // existing vocabulary rather than inventing a second one.
+ if (e instanceof HerdrException he && he.code() != null
+ && he.code().endsWith("_not_found")) {
+ p.state = Pending.State.NOT_DELIVERED;
+ }
sent = p;
sendError = e;
}
diff --git a/fleetd/src/main/java/dev/ltms/fleet/msg/MessageService.java b/fleetd/src/main/java/dev/ltms/fleet/msg/MessageService.java
index 9ce814d..8773b5d 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/msg/MessageService.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/msg/MessageService.java
@@ -94,16 +94,22 @@ public final class MessageService {
TIMED_OUT_WORKING,
/**
* Timed out with no confirmed delivery. Despite the name, this does not mean the message
- * is sitting in a queue — on every route it will not arrive later. {@link #send} reaches
- * this outcome through {@link Injector#cancel}, whose result tells the routes apart:
+ * is sitting in a queue. {@link #send} reaches this outcome through {@link Injector#cancel},
+ * whose result tells three routes apart:
* {@link Injector.Cancellation#CANCELLED} means the message was still queued and this call
- * removed it, so the target saw nothing; {@link Injector.Cancellation#NOT_DELIVERED} means
- * an earlier attempt already decided the message's fate — the injector's call to the
- * target's terminal ({@link dev.ltms.fleet.herdr.AgentControl#send}) threw, or the queue
- * was cleared because the target never became ready or was abandoned — and {@code cancel}
- * is only reporting that pre-existing state. On the failed-attempt route the terminal may
- * already hold a partial paste from before the call threw, so only the {@code CANCELLED}
- * case establishes that the target saw nothing.
+ * removed it, so the target saw nothing and it will not arrive later;
+ * {@link Injector.Cancellation#NOT_DELIVERED} means nothing was ever sent — the queue was
+ * cleared because the target never became ready or was abandoned, or the injector's call to
+ * the target's terminal ({@link dev.ltms.fleet.herdr.AgentControl#send}) failed with a herdr
+ * error that this codebase already treats as a confirmed absence — so this route too
+ * establishes that the target saw nothing and it will not arrive later; but
+ * {@link Injector.Cancellation#ATTEMPTED} (fleetd #551) means that call was made and its
+ * outcome is unknown. {@code agent.prompt} pastes and submits in one call, so on
+ * this route the target may hold a complete, already-submitted turn and be working on it
+ * right now — {@link Outcome#TIMED_OUT_WORKING}'s meaning, reported here as
+ * {@code TIMED_OUT_QUEUED} only because this caller never observed the pickup. Only
+ * {@code CANCELLED} and {@code NOT_DELIVERED} establish that the target saw nothing;
+ * {@code ATTEMPTED} does not.
*/
TIMED_OUT_QUEUED,
/** Another send to this session was in flight for the whole window. */
@@ -313,16 +319,18 @@ public final class MessageService {
/**
* Targets whose last send timed out with {@link Outcome#TIMED_OUT_QUEUED} (CB-640) — {@link
* #send} called {@link Injector#cancel} and got back something other than {@code DELIVERED}.
- * That covers more than one history: the message may have still been queued and {@code cancel}
- * removed it right there, or an earlier attempt may already have failed (the call to the
- * target's terminal threw) or been abandoned (the target never became ready, or was torn
- * down). What holds on every route: the message will not arrive later, and it is not sitting
- * in any queue. What does NOT hold on every route: that the target saw nothing — a failed
- * delivery attempt can leave a partial paste behind. Set where {@link #send} already computes
- * {@code wasDelivered} for that outcome; no queue is kept here, only the fact that the send
- * ended with no confirmed delivery. Cleared the same way as {@link #strandedReplies}: the next
- * accepted delivery for the target ({@link #send} opening a fresh waiter) or a teardown
- * ({@link #abandon}).
+ * That covers three histories, not one: {@link Injector.Cancellation#CANCELLED} — the message
+ * was still queued and {@code cancel} removed it right there; {@link
+ * Injector.Cancellation#NOT_DELIVERED} — nothing was ever sent, because the target never became
+ * ready, was torn down, or the call to its terminal failed with a herdr error this codebase
+ * already treats as a confirmed absence; or {@link Injector.Cancellation#ATTEMPTED} (fleetd
+ * #551) — the call to the target's terminal was made and its outcome is unknown, so the target
+ * may already hold a complete, submitted turn. Only the first two mean the message will not
+ * arrive later and the target saw nothing; on the third it may already have arrived in full.
+ * Set where {@link #send} already computes {@code wasDelivered} for that outcome; no queue is
+ * kept here, only the fact that the send ended with no confirmed delivery. Cleared the same way
+ * as {@link #strandedReplies}: the next accepted delivery for the target ({@link #send} opening
+ * a fresh waiter) or a teardown ({@link #abandon}).
*/
private final ConcurrentHashMap