MessageService collapses the new ATTEMPTED state into TIMED_OUT_QUEUED, so a possibly-delivered message is reported as one that will never arrive #571
Closed
opened 2026-09-12 12:49:15 +02:00 by ltms
·
7 comments
No Branch/Tag Specified
main
worker/fleetd-612-unita-87807e-1
worker/612-b3-mcpwirings-da2b58-3
worker/612-b2-cb185-176d3a-2
worker/612-b1-completion-457459-1
worker/612-agaps-73a926-2
worker/608-sleeps-3a64ff-3
worker/621-b4520b-1
worker/618-b83894-2
worker/fleetd-615-e05481-5
worker/lead-autocompact-5f1ab2-3
worker/fleetd-613-f85deb-3
worker/fleetd-608-flaky-nudge-test-d0c2d1-3
worker/lead-context-gauge-ad404f-1
worker/gauge-wiring-9158c1-4
worker/redeploy-slowstart-ead0e5-5
worker/charter-bytes-13668c-6
worker/rollover-outcome-291483-2
worker/589-f64303-2
worker/593-1a8025-5
worker/589-fcd2aa-1
worker/568-9fdaa2-3
worker/571-attempted-outcome-5739f7-2
worker/581-completionresolver-cas-sites-0542b7-6
worker/562-loop-health-wiring-test-99611c-5
worker/562-surface-loop-health-7df5cc-4
worker/575-waiter-cleanup-sites-62ad80-1
worker/572-answer-lock-release-46a9ae-5
worker/567-probe-channel-leak-a38fc5-6
worker/551-record-before-send-7cbf56-1
worker/561-listener-fanout-survives-a-throw-61d538-2
worker/555-redeploy-main-flow-seam-65c2f5-2
worker/556-injector-owns-registration-e027a5-1
worker/552-post-restart-mktemp-abort-bc2672-4
worker/553-onstatus-completion-leak-0da881-2
worker/550-shasum-linux-196132-1
worker/538-loop-dies-on-error-4a5eeb-6
worker/426-health-coverage-ef1fd4-4
worker/504-failed-reported-clean-3cfd66-3
worker/537-capturedlog-close-e4c437-2
worker/459-broken-link-targets-cadc17-5
worker/535-appender-leak-fe74c1-1
worker/512-part2-shutdown-detection-434701-9
worker/529-logger-level-sweep-2a5533-8
worker/528-drain-gate-call-site-5de83d-7
charter/forge-mcp-vs-token
worker/521-swap-guard-unpinned-28e931-5
worker/519-probe-test-harness-d25ab8-4
worker/525-logger-level-leak-1b4eb0-6
worker/518-fleetmcp-resolver-wiring-8ef96c-1
worker/512-drain-complete-line-7edd71-3
worker/517-abort-branch-and-jar-id-41b641-2
worker/500-9e52c9-3
worker/509-4912f4-2
worker/511-9a4b23-1
worker/493-479f45-2
worker/505-03f8b2-1
worker/492-followup-detect-unclear
worker/501-a31fa0-7
worker/498-451d1c-5
worker/494-1015ce-2
worker/492-209647-1
worker/489-001902-2
worker/480-relative-handover-path-906323-1
worker/480-b-handover-skill-45bf1f-5
worker/474-followup-source-pin-f54a55-17
worker/474-charter-check-on-reload-f54a55-17
worker/466-quarantine-repeatcount-report
worker/393-opencode-skill-seeding-71854b-13
worker/469-canonical-tool-names-2a472a-16
worker/466-quarantine-escalation-5ae9c1-15
worker/446-hot-exhausted-pattern-0af580-6
worker/464-charter-tool-name-guard-a85635-12
worker/463-listfleet-default-fails-open-f1c76c-11
worker/458-invariant-5-by-purpose-862f9a-10
worker/439-coordinator-row-gate-bc032a-8
worker/449-herdr-protocol-576015-4
worker/450-abstract-spawn-599e1c-5
worker/437-ack-refuses-177d91-1
worker/444-placement-window-feb56a-2
worker/440-helddurable-derived-d462d7-13
worker/425-rework-placement-resolve-c58ba1-9
worker/421-lead-peek-held-msgs-cdbad2-10
worker/435-fixed-policy-cap-fe11de-12
worker/422-gate-state-observability-9e79d6-11
worker/431-memberregistry-live-readers-cdbad2-10
worker/424-architect-slot-hot-038b41-7
worker/422-model-gate-spawn-c29f48-6
worker/425-default-profile-live-f55534-8
worker/415-coverage-wording-2cbf9c-5
worker/416-3ad1da-1
worker/418-588283-3
worker/deterministic-stamp-race-409-3cb7b6-10
worker/armed-reads-live-config-404-ed931f-9
worker/reply-peer-refusal-391-5a34bd-7
worker/models-allowlist-aa9e9b-3
worker/ttl-stamp-race-399-f1122f-8
worker/scrub-receipt-400-316b3e-5
worker/exhaustion-detection-395-105105-6
worker/scrub-abort-394-316b3e-5
fix/scrub-uid-abort
worker/task-scrub-517574-2
worker/t386-clock-bd5b78-4
worker/t384-scrub-813790-5
worker/t381-cc-748314-2
worker/t373-336973-2
worker/t365-3920c5-3
worker/t358-6e989b-1
worker/t355-8b321c-1
worker/fleetd-369-hermetic-git-tests-e8b19a-3
worker/fleetd-368-stale-lead-binding-f5682e-2
worker/fleetd-360-deploy-units-0d3793-1
worker/359-dead-lead-tabs-f1253b-4
worker/362-worktree-skills-c03e51-3
worker/361-coord-visibility-655144-1
362-plugin-visibility-and-drift
worker/errscan-bed2ca-2
worker/amqp-log-identity-bed2ca-2
worker/withdefaults-guard-561704
worker/sleepguard-82076d-1
worker/fd334-9ee1b6-5
worker/fd348-f1ab27-4
worker/fd335-a71c35-1
worker/fd342-174a17-2
worker/fd345-490d0f-3
worker/fleetd-337-5ec7d4-21
worker/fleetd-341-af5a6b-24
worker/fleetd-339-5ca0a2-23
worker/fleetd-338-83a4a1-22
worker/fleetd-333-281f46-18
worker/fleetd-329-11bdbb-16
worker/fleetd-330-2770fb-17
worker/fix-326-50506e-15
worker/fix-324-3e9bbf-14
worker/fix-323-b8287d-13
worker/fix-316b-bd0860-11
worker/fix-318-76ca36-9
worker/fix-317-486aec-8
worker/fix-315-ce47c5-6
worker/fix-307-275890-6
worker/fix-308-b4f664-7
worker/fix-309-ec3939-8
worker/fix-310-7a3974-9
worker/fix-302-52ad0e-9
worker/fix-298-ce1acb-8
worker/fix-297-66bd11-7
worker/fix-296-104622-6
worker/fix-293-bare-closetab-eb22b5-3
worker/fix-280-gone-ask-lapse-bca98e-2
worker/fix-290-reapidle-guard-coverage-9b0dd1-1
worker/fix-285-trust-seed-8f3565-10
worker/fix-284-backend-error-seat-85912c-11
worker/fix-282-chained-ask-e6d0bb-8
worker/fix-283-teardown-leaks-f40dfa-9
worker/fix-281-pin-handler-actions-4921ac-7
worker/audit-rendezvous-lifecycle-d072ae-2
worker/audit-health-placement-1a2476-6
worker/audit-teardown-exits-e207a5-3
worker/audit-launcher-asymmetry-27e370-4
worker/audit-rest-authz-6ca53c-5
worker/investigate-275-abandon-asking-fdef52-8
worker/fix-274-worktree-leak-b0095d-7
worker/fix-273-exhausted-pattern-9665b5-6
worker/fleetd-267-model-check-bd8068-1
worker/fleetd-131-archunit-18b834-7
worker/fleetd-266-sshagent-rename-a014ff-6
worker/fleetd-184-uid-claim-8e1f31-4
worker/fleetd-184-warn-b381ee-10
worker/fleetd-184-docs-be1d12-9
worker/fleetd-257-9bf010-7
worker/fleetd-103-23a113-6
worker/fleetd-247-342356-5
worker/fleetd-116-04dea8-4
worker/fleetd-252-a830e0-3
worker/fleetd-111-7e8673-9
worker/fleetd-155c-f8ef4b-8
worker/fleetd-176-b928ca-3
worker/fleetd-249-7a7878-2
worker/cb248-composition-root-b-9acdf7-15
worker/cb148-envrc-default-fa6c82-12
worker/cb201-unit5-wiring-6c12e6-8
worker/cb241-fallback-echo-1175e9-11
worker/cb149-trust-dialog-2392a5-9
worker/cb134-148-overlay-visible-c9b986-10
worker/cb234-session-id-keyed-04e1fc-1
worker/cb201-unit3-nudge-abdf5c-6
worker/cb201-unit2-policy-c1102c-5
worker/cb201-unit4-outcome-a13bfa-7
worker/cb201-unit1-classifier-91b9b1-4
worker/cb201-227-refine-831980-3
worker/cb175-model-readback-0f085f-1
worker/cb222-charter-tmpdir-17f013-1
worker/cb226-architect-slot-race-cd3aa8-3
worker/cb224-worktree-root-group-024523-2
worker/cb-123-role-demotion-c600f7-2
worker/cb-219-opencode-roots-1f677e-1
worker/cb214-claude-session-id-b9eab4-4
worker/cb213-zdotdir-wrong-process-dd6de4-3
worker/cb211-exhaustion-classification-9546e0-2
worker/cb137-ambiguous-task-4df3d8-4
worker/cb209-agentsessionid-4dfdb6-2
worker/cb185-hostenvnames-2692b5-3
worker/cb206-opencode-sqlite-128718-2
worker/cb185-worktree-group-fc0c99-1
worker/cb-137-ask-ticket-e7760c-2
worker/cb-172-broker-uri-d36ae4-4
worker/cb-175-model-readback-76ead6-3
worker/cb-161-pane-ancestry-293510-1
worker/cb-164-rebase-885863-8
worker/cb-164-empty-scrape-false-success-1a80af-3
fix/cb-197-ticket-ttl-from-completion
worker/cb-189-remote-url-coverage-4692f3-1
worker/cb-185-blockers-027756-4
worker/cb-192-gap-log-11b631-2
worker/cb-633-fix-5f4396-3
worker/cb185-router-d6436d-3
worker/cb185-router-routing-gaps-9e9d33-3
worker/cb185-paneids-992586-2
worker/cb-633-allow-list-union-ed374b-1
worker/cb-157-credential-in-remote-url-496e44-2
worker/cb-641-health-herdr-evidence-8f1f54-6
worker/cb-640-health-msg-evidence-99c9cd-1
worker/cb-642-fleets-status-skill-bbbc40-5
cb-634-ide-mcp
worker/lead-comms-wiring-c014b9-7
worker/lead-mailbox-c19577-6
worker/autocompact-window-82bc2f-5
worker/cb-634-probe-18056f-4
worker/cb635-broker-urienv
worker/cb-632-config-retry-8e0efa-7
lead/cb-622e-claude-md
lead/cb-622-followup
worker/cb-622a-165dff-1
lead/cb-622d-opencode-mount
worker/cb-622b-717c67-2
worker/cb-622c-ab7759-3
worker/cb-617b2-20ca4b-3
worker/cb-617a-5c2f4a-1
worker/cb596-4e49ef-3
worker/cb586-10500c-1
worker/cb-606-b9343a-25
worker/cb604-1445f8-24
worker/cb582-477374-21
worker/cb584-8c2281-22
worker/cb600-e6b9a9-20
worker/cb602-ce257f-19
worker/cb601-b42837-18
worker/cb598-6c7ba7-17
worker/cb599-740fe4-16
worker/cb597-282224-15
worker/cb590fix-185e9a-10
worker/cb528-recovery-race
worker/cb594-96bead-8
worker/cb590-916766-2
worker/cb527-997d99-3
worker/cb592-env-leak-3cbf9c-1
worker/cb588-async-ticket-nudge-3218f7-5
worker/cb578b-9dcb13-6
worker/cb581-d24826-5
worker/m2-u5-ef8c42-15
worker/cb578a-516499-2
worker/cb576-01a04b-17
worker/cb579-lead-tab-acba06-20
worker/cb580-terminal-health-ed6058-21
worker/cb577-f36fdc-18
worker/cb573b-3db06f-16
worker/cb568c-f36fdc-18
worker/cb568-drop-cause-c3ac1c
worker/cb575-cancelled-notification-c3ac1c
worker/m4-sol-a2cbec-3
worker/cb574-async-ask-c3ac1c
worker/cb573-health-model-8ca857-14
worker/cb572-unknown-target-7f2e35-13
worker/u4-700706-9
worker/u3-b9fcb6-6
worker/u2-ef5b68-4
worker/u1-469dce-1-clean
worker/u1-469dce-1
worker/cb-564-health-events-70cf7e-2
worker/cb-565-recycle-drops-role-98e58f-3
worker/cb-563-missing-reply-df2866-1
worker/cb-562-readiness-gate-silent-6c23c9-3
worker/cb-560-architect-presence-da8155-1
worker/cb-561-architect-silent-off-a71cab-2
worker/cb-548-bind-architect-slot-fe1b8c-1
worker/parity-overlay-settings-5fb711-1
secrets-central-store
cb-559-hot-key-correction
cb-557-fleet-role-pools
worker/cb-553-maxload-explicit-spawn-305ee3-6
worker/cb-551-idle-lead-heartbeat-f1633c-1
worker/cb-544-drain-preserves-worktree-925fad-3
worker/cb-552-docs-sync-1cb9cf-4
worker/cb-548-rendezvous-guard-rebased
worker/cb-548-rendezvous-guard-116b53-10
worker/cb-548-authz-v2-586df6-8
worker/cb-548-authz-264363-5
salvage/cb-528b-codex-home
salvage/cb-528a-codex-launcher
CB-518-primary-flow
feature/peer-launcher-spi
cb-103-injector
v1.1.0
v1.0.0
Labels
Clear labels
blocked
needs-live-proof
ready-to-delegate
silent-default
Cannot start until something else lands. The body says what.
Merged and green, but never shown working on the running daemon. Not the same as done.
Scope, files and acceptance criteria are written. A worker can be briefed from the body alone.
A feature that compiles, passes tests, and ships turned off. Nine recurrences and counting.
No Label
Milestone
No items
No Milestone
Projects
Clear projects
No project
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: fleet/fleetd#571
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Follow-up to #551, split out of it deliberately so #551 is not blocked. Filed after verifying #551's PR #569; the worker on that unit flagged it and the diagnosis is theirs.
The shape
#551 gives
Injectora thirdPending.State,ATTEMPTED, meaning "send()was called and we do not know whether the text reached the pane". It also givesCancellationa matching third answer, so the uncertainty survives the call that reports it.MessageService.java:973is the only reader ofCancellationoutsideInjector:ATTEMPTED != DELIVERED, so it becomesTIMED_OUT_QUEUED. The three-valued answer is flattened back to two, one layer above the place #551 just fixed.This is the same defect shape #551 fixed, moved up the call stack.
cancellationOfwas a two-way split on a three-way question; this is now the surviving one.What is and is not a regression
Measured, so nobody re-derives it:
HerdrExceptionafter the paste setNOT_DELIVERED→cancellationOf→NOT_DELIVERED→wasDelivered = false→TIMED_OUT_QUEUED.ATTEMPTED→Cancellation.ATTEMPTED→wasDelivered = false→TIMED_OUT_QUEUED.Caller-visible behaviour is identical. #551 is not a regression and should merge on its own. What changed is that the information now exists and is thrown away, where before it was never captured. That is why this is a separate ticket and not a bug in #551.
Why it matters
TIMED_OUT_QUEUEDtells the caller the message will not arrive later. On theATTEMPTEDroute the message may already have arrived — in full.AgentControl.sendcalls herdr'sagent.prompt, which pastes and submits in one call, so the target may be working on that exact turn while the caller is told it never landed.The caller's natural recovery from
TIMED_OUT_QUEUEDis to resend. On this route that is a double delivery: the same brief typed into the pane twice. #551's own javadoc makes the point — reading the uncertain answer as a confident negative "invites a resend of text that may already be sitting in the pane, which is worse than the ambiguity itself."I have not observed a double delivery in production. The window needs a herdr response-half failure during a
sendthat also times out.The decision this needs
Outcomecurrently has no value for "we do not know". The options, and none is obviously right:TIMED_OUT_UNCONFIRMED. Honest, and every caller that switches onOutcomemust be found and updated — the compiler helps only where the switch is exhaustive. Checkfleet_send's MCP surface and anything that renders an outcome to an operator.ATTEMPTEDtoTIMED_OUT_WORKING. Cheap, no new value, and arguably closer to the truth — the worker may well be working. But it asserts a positive on evidence that does not support one, which is the mirror of the current bug rather than a fix.TIMED_OUT_QUEUEDand fix only the javadoc so callers know the guarantee has three routes and one is uncertain. This is what PR #569 does, and it is the minimum. It leaves the wrong value in place and relies on every caller reading the doc.My view: 1, unless someone can show no caller distinguishes them. 3 has already landed with #551 and is not sufficient on its own.
Acceptance
Outcomeand say in the PR what each does with the new or remapped value — the same checklist #551 was held to forPending.State. AgrepforOutcome.across main and test sources, with a positive control proving the pattern matches.ATTEMPTEDdoes not report an outcome promising the message will never arrive. Red before the fix.sed, anchor counted withgrep -Fxc(notawk -v— it escape-processes the value and silently counts 0 on any line holding\tor\n), count down by exactly one, red with the test's own assertion message and observed value, restored byte-identical undershasum -a 256, green control, and the proof cell run against the un-mutated tree first to confirm it reports not-applied.Do not start this until #551 has merged
It edits the same region and will conflict.
Related: #551 (the layer below, where
ATTEMPTEDis created), #513 (the javadoc that enumeratesTIMED_OUT_QUEUED's routes — this ticket adds the third), #512 (one symbol carrying two states).Unblocked — #551 merged as
384867d(PR #569).Decision: option 1. Add a fourth outcome.
I made the design call myself rather than delegate it, and I measured the thing the ticket said would decide it: how many callers there are, and whether the compiler finds them. Here is that measurement, run on
mainat204da67.Every reader of
MessageService.Outcome— three files, and that is allNo test file switches on it. No
Outcome.values()and noOutcome.valueOfanywhere in main or test.A correction to my own acceptance criterion in this ticket. I asked for "a
grepforOutcome.across main and test sources". That grep is wrong, and I ran it first and got a wrong answer. Two ways:FleetMcp.javaentirely. Every switch there is writtencase REPLIED ->, with no qualifier, so the file never appears. That is the whole MCP rendering surface — the most important reader — invisible to the search the ticket asked for.ConfigRef.java, which has its own unrelatedOutcomerecord for config reloads. A false positive that reads as a caller.So: search by constant name, not by
Outcome., and keep the positive control the ticket already asks for. This is the same lesson as [a zero match is not a finding] — a non-zero count needs the control just as much as a zero does, because a wrong pattern can be wrong in both directions at once.Where the compiler helps, and the one place it does not
MessageService.sendOutcomeLabel:645defaultFleetMcp.formatReply:786defaultFleetApp.writeReply:628default ->at:641, wrapping an inner switch expression withdefault -> "done"at:649So the cost of option 1 is two compile errors that point you straight at the code, plus one site that must be found by reading. That is a small, bounded cost, and it settles the ticket's "unless someone can show no caller distinguishes them" condition: they do distinguish them, and almost all of them will say so loudly.
The hazard that makes this worth doing properly
FleetApp.java:649reads:Add
TIMED_OUT_UNCONFIRMEDand that comment becomes false. The branch becomes reachable, and the REST caller is told"status": "done"— the delegation completed — for the one case where we do not know whether the message even arrived. That is strictly worse than today's"queued". A comment claiming an invariant is a free test case; this one is about to go false, so it gets an explicit arm and a test, not an edit.Why not the other two
Option 2 (map
ATTEMPTEDtoTIMED_OUT_WORKING) asserts a positive from evidence that does not support one. It is the same defect as today's, pointing the other way. Rejected.Option 3 (javadoc only) already landed with #551. It leaves the wrong value in place. Not sufficient on its own, as the ticket says.
The name is
TIMED_OUT_UNCONFIRMED.FleetMcp.java:807renders a timeout outcome asname().toLowerCase().replace("timed_out_", ""), so it reads out as "unconfirmed" with no extra code — but the arm still has to be added to that switch deliberately, not left to the string trick.Delegating the implementation now.
CORRECTION — this supersedes the brief. Read it before you commit.
Credit: the fleet01 lead raised this while the unit was in flight.
Add one requirement: make the compiler the enumeration, not the grep
My earlier comment told you to find every reader with a hand-listed alternation of constant names. That command is stale the moment your own commit lands — it will be missing
TIMED_OUT_UNCONFIRMED— and it goes stale silently, because a fixed alternation still returns plenty of hits. That is the same failure as theOutcome\.grep it replaced: a plausible total with the membership wrong.It is good enough to find the sites today, because every existing switch mentions at least one of the eight. It is not good enough to leave behind. So:
Every switch over
MessageService.Outcomemust be exhaustive and carry nodefault. Then adding a ninth constant is a compile error at every site that has to decide about it, and nobody ever has to run this search again.Measured on
mainat204da67— three of the five are already there:default?MessageService.java:646sendOutcomeLabelFleetMcp.java:786formatReplyFleetApp.java:641— outer switch statement inwriteReplyFleetApp.java:649— inner switch expression,default -> "done"(
FleetMcp.java:775andFleetApp.java:685switch onAskResult.Outcome, a different enum. Leave them alone.)The inner one at
:649is the required change.default -> "done"produces a value that lies: it would tell a REST caller the delegation completed, for the one case where we do not know whether the message arrived. Delete thedefaultand list every constant. Some arms will be unreachable in practice because the outer switch handles them first — write them anyway. An unreachable-but-explicit arm that the compiler forces you to keep correct beats adefaultthat silently absorbs the next constant.The outer
defaultat:641may stay if you want it: "everything not terminal is a 202" is a real rule, and with the inner switch exhaustive the compiler still stops you at the line that matters. Your call — state which you chose and why.The property to hold, in one sentence: adding a constant to
Outcomemust fail the build at every site that decides about it. Prove it. Add a tenth constant temporarily, show me the compile errors and which files they name, then remove it. That is a better proof than any grep, and it is the acceptance criterion I should have written in the first place.Keep the reflective check
grep -rn 'Outcome.values()\|Outcome\.valueOf' src/main/java src/test/javastays. No compiler check and no name search catches the reflective back door. I measured zero today; re-run it and say so.Why bare constants hid the most important reader
Worth recording, because it will recur. Before Java 21 an enum
caselabel had to be unqualified. Java 21+ permits the qualified form, and this module targetsmaven.compiler.release25, socase Outcome.REPLIED ->would compile — but nobody writes it, so the bare form is what is in the tree. The consequence:grep 'EnumName\.'misses enum switches almost always, and misses precisely the exhaustive ones — the readers that matter most, because they are the ones the compiler would have forced you to update.Grep cannot see these readers.
javaccannot miss them. That is the whole argument for the change above.Nothing else in the brief changes
The decision is still
TIMED_OUT_UNCONFIRMED. The three tests still stand, and the mutation discipline is unchanged. This adds one requirement and one proof; it removes nothing.CORRECTION 2 — supersedes the compile-error proof in my last comment. Read before you commit.
Credit again: the fleet01 lead. Both points below are theirs; the measurements are mine.
1. ORDER IS LOAD-BEARING. Delete the
defaultarms FIRST, then add the temporary constant.My last comment said "add a tenth constant temporarily, show the compile errors and which files they name, remove it" and did not say when. Run it in the wrong order and it certifies the opposite of what it looks like it certifies:
defaulttodayMessageService.java:646FleetMcp.java:786FleetApp.java:641FleetApp.java:649So a worker who adds the constant first gets a tidy two-file error list naming only the sites that need no work, and saying nothing about
:649— the one that produces a value lying to a REST caller. It reads as a clean, complete enumeration that happens to omit the entire job.The thing you are hunting is also the thing that suppresses the alarm. A
defaultis what makes the compile error not happen, so a proof that relies on compile errors cannot see adefault.The order: delete both
defaultarms, then add the temp constant, then all four must error. If fewer than four error, you missed adefault. Report the count.2. The compile proof enumerates SWITCHES, not READERS. Here is its blind spot, measured.
Equality comparisons, ternaries and
!=checks all compile fine with a new constant and fall silently to the else branch. There are 8 of them, in 2 files, andjavacwill not say a word about any:No
EnumMaporEnumSetoverOutcome— theEnumSethits inmember/are overCapability, a different enum. So comparisons are the only third door here.Give each of those 8 sites a one-line verdict in your PR: does
TIMED_OUT_UNCONFIRMEDreach it, and if it does, is the else branch right for it? Do not assume a timeout outcome cannot reach the two? "reply" : "transcript"ternaries — check the path and say so.3. Why my first grep was not wrong, just mislabelled — this is the useful part
I called
grep 'Outcome\.'a broken search. It is not. It is the comparison finder, and I mislabelled it as the reader finder. The two forms partition the codebase almost perfectly:Outcome.Xhitscase X ->label linesFleetMcp.javaMessageService.javaFleetApp.javaFleetMcp.javahas zero qualified references. It is pure switches, which is exactly why the qualified grep could not see it and whyjavaccannot miss it. The comparisons, meanwhile, are written qualified — so the grep finds all 8 of them and the compiler finds none.Three doors, three instruments, none of which sees all of it:
javac(nodefault) → finds the switchesgrep 'Outcome.values()\|Outcome\.valueOf'→ finds the reflection (zero today, re-run it)So keep the constant grep for this turn. Not as the enumeration — as the cross-check on the compile proof's blind spot. Run all three and report all three.
Nothing else changes
Still
TIMED_OUT_UNCONFIRMED. Same three tests, same mutation discipline. This fixes the order of one proof and adds a second one.CORRECTION 3 — the door count is FIVE, not three. Read before you commit.
Credit: the fleet01 lead again. They predicted two more doors; I measured both, and one of them is not empty.
DOOR 4 —
name()/ordinal(). Two live sites. No instrument I gave you sees these.MessageService.java:1371is the one that matters, and it is worse than a blind spot — it is the designed path. Its own comment says so:TIMED_OUT_UNCONFIRMEDis a timeout outcome, so it carries no reason, so it takes theelse. The raw enum name goes onto the wire asno reply — timed_out_unconfirmed, with no code change and no compiler check. You will ship a new externally-visible token without touching a line.Decide deliberately whether that string is the one you want a caller to read, and say which you chose. Do not let it happen by default.
FleetMcp.java:807sits inside thecase TIMED_OUT_WORKING, TIMED_OUT_QUEUED, BUSY ->arm, sojavacwill force you to decide where the new constant goes — but it will not check the string that comes out. Adding it to that arm renders "worker unconfirmed", which reads correctly. Confirm that yourself rather than trusting me.Door 4a — constant names as quoted string literals — is ZERO, and I controlled the pattern rather than trusting the zero (a regex of the same shape matches in 4 files elsewhere). Nothing matches on these names as text. Re-run it and say so:
No
Outcome[]and no ordinal indexing either — so no fixed-size array to throw at runtime on the new member.DOOR 5 — THE WIRE FORM. Outside the tree, so outside every instrument.
This enum has three serialized surfaces. A consumer matching on any of them is a reader no grep of this repo can reach — another service, a script, a dashboard, a Claude session parsing MCP output. Adding a constant is a protocol change for them, silent on both sides.
FleetApp.writeReply"status":working/queued/busy/failed/backend_exhausted/doneFleetMcp.java:807[no reply within Nms — worker <name minus timed_out_>; retry or poll status]MessageService.java:1371no reply — <raw enum name, lowercased>New required deliverable: your PR must list every serialized string this change adds or alters, on all three surfaces, exactly as a consumer would see it. You are not chasing the consumers — you cannot reach them. You are putting it where whoever owns one will look. That is the same push/pull split the charter draws: a push needs the recipient free at send time, a pull only needs them to look before acting.
The five doors, and the instrument for each
javac, everydefaultdeletedvalues()/valueOfgrepname()/ordinal()Run 1–4 and report all four counts. Write door 5 out by hand.
The count has gone 1 → 2 → 3 → 5 this afternoon, and every step was a measurement, not a prediction. So do not treat five as final either. If you find a sixth, say so; that is a finding, not a failure to follow the brief.
Nothing else changes
Still
TIMED_OUT_UNCONFIRMED. Same three tests, same mutation discipline, same order rule (delete thedefaultarms first, then add the temp constant, then all four sites must error).CORRECTION 4 — three measurements, and a sibling enum that already solves door 5. No new work for you except one sentence.
Credit: the fleet01 lead. All three checks are theirs; the measurements are mine.
Door 6 — readers inside the repo but outside the Java source roots. MEASURED: no executable reader.
Every grep in the door table was scoped
src/main/java src/test/java. That scoping is itself a blind spot with a shape, and the fleet01 lead was right to point at it —scripts/in this repo already parses daemon output, so a shell script matching an outcome token would have been house style, not a stretch.Measured repo-wide:
docs/CB-201-227-Refinement.md,docs/CB-308-Multi-Host-Federation.md,docs/M4-Fleet-Health.md×2,docs/wiki-audit.md×2,fleetd/fleetd.example.yaml×2 (both in comments about the classification, not values). None of these parse or match — they are documentation.backend_exhausted,timed_out_,no reply —) outside*.java: ZERO. Positive control:git grepreaches non-Java files fine here (5 files matchhealthz).So no script, no schema, no non-Java client reads this enum. Door 6 is empty of readers. Door 5 shrinks to genuinely-external consumers, which the PR deliverable already covers.
One knock-on, out of scope but worth one line in your reply:
docs/wiki-audit.md:140-141already records that a documented outcome list is incomplete. Your new constant makes those doc lists staler. Note it; do not go fix them.Door 5's contents — CHECKED, and my table was right
The fleet01 lead asked whether anything overrides the enum's string form, because if a
toString(),@JsonValueor custom serializer existed,.name()and the wire form would be two different tokens for one outcome and my table would have listed one as if it were both.MessageService.Outcomeis 8 bare constants with javadoc and nothing else — notoString(), no Jackson annotation, no serializer. So.name().toLowerCase()is the wire form. The table stands.Door 3's zero retires a whole hazard class — say so in the PR
Outcome.values()/Outcome.valueOfis 0, re-measured, exit 1, with a control confirming the pattern works on other enums here (MemberSession.State.values()atFleetMetrics.java:80).The inference neither of us had drawn: nothing parses a string back into this enum. It is write-only to the wire. So the entire round-trip class does not exist here — no old persisted value failing to parse in a new binary, no new value failing to parse in an old reader. That is normally the most expensive part of adding an enum constant, and it is already ruled out.
Put that in the PR explicitly. It is the only place that will ever record the absence, and a future change that adds a
valueOfreintroduces the whole class silently.The thing I found while checking:
ReplyOutcomealready does this properly, 400 lines upMessageService.java:166declares a sibling enum that is everythingOutcomeis not:An explicit
wireName, pinned in the constant, deliberately decoupled from.name(). The house pattern for a wire-facing enum in this exact file already exists, andOutcomedoes not follow it — which is precisely why:1371leaks.name().toLowerCase()onto the wire.Do NOT fix that in this PR. Giving
OutcomeawireNametouches 8 constants and 3 call sites and is its own change; folding it in would make a unit that already carries four corrections unreviewable. I have filed it separately.What I want from you instead is one sentence in the PR: that
TIMED_OUT_UNCONFIRMEDintroduces the wire tokentimed_out_unconfirmedimplicitly, via.name(), rather than by a pinnedwireNamelike its sibling — and that this is known and deferred, not overlooked.Door table, current
javac, defaults deletedvalues()/valueOfname()/ordinal().name()grepgit grepwith pathspec excludesSix doors, two of them now measured empty. Still do not treat six as final.
Nothing else changes
TIMED_OUT_UNCONFIRMED. Same three tests, same mutation discipline, same order rule.CORRECTION 5 — HOLD on PR #580. The new
FleetMcparm is unasserted, and I proved it is a gap this PR creates.The work is good. All six doors were addressed, the exhaustiveness proof ran in the required order, and both new tests carry real mutation proofs. Nothing below is a criticism of the reported work — it is the mutation I run against the half the worker did not change.
The survivor
Branch
worker/571-attempted-outcome-5739f7-2, extracted fresh. PristineFleetMcp.javashafcba00011880827c.Baseline:
Tests run: 1768, Failures: 0, Errors: 0, Skipped: 0— BUILD SUCCESS. That matches the worker's reported number exactly.Mutation —
FleetMcp.java:811, the new arm, located fresh. Anchorgrep -Fxc1 → 0, marker present. Replaced the "delivery unconfirmed" text with the generic queued-timeout text.Survived. Restored; sha matches
fcba00011880827c.So the MCP text that exists specifically to warn a caller against a blind retry can be replaced with the text that invites one, and nothing fails.
The control, which is what makes this a finding rather than a guess
A survivor has three causes. I ruled out the other two with a positive control — mutating the pre-existing sibling arm at
:806(TIMED_OUT_WORKING, TIMED_OUT_QUEUED, BUSY):Killed. So:
formatReplyis reachable from tests —FleetMcpTest:436also asserts its[question]arm.The only remaining explanation is the real one: this PR adds a tenth arm to a nine-arm switch on a surface that is otherwise asserted, and does not assert the new one. That is a per-site gap created here, not inherited — which is why it is fair to hold the PR for it rather than file it separately.
Worth naming: this is the exact shape of #577, the sweep running in parallel with this ticket. One method, several arms carrying one invariant, coverage at some sites and not others. It is easy to miss precisely because the total is healthy — 323 assertions in
FleetMcpTest, 1768 green.A correction to my own method, for the record
My first pass grepped the test tree for
no reply withinand got zero, and I was about to read that as "the whole MCP timeout surface is untested". That zero was wrong. The covering test asserts on a fragment (queued; retry or poll status]), so my pattern could not see it. The control is what corrected me, not the grep. A zero match is a fact about the pattern until a positive probe says otherwise — and here the positive probe reversed the conclusion, from "pre-existing gap, file separately" to "gap created here, hold the PR".What is required to merge
One test in
FleetMcpTest, next tosendTimesOutWithAWorkingNote, which is the model to copy.formatReply's output forTIMED_OUT_UNCONFIRMED— specifically that it carries the delivery-unconfirmed wording and does not tell the caller to retry the way the queued/working arm does. The distinction between those two messages is the entire point of this ticket; pin it.:811arm's text with the:806arm's text, show the named failure, restore, paste the matchingshasum -a 256.Also fine as-is, no action needed
Your call on
MessageService's.name().toLowerCase()fallback for the new outcome is accepted. You are right that it already renders the other three timeout outcomes the same raw way, and thatTaskView.reasonis not the REST or MCP surface. Stating it as a deliberate choice rather than letting it pass silently is exactly what I wanted. That whole area is #578's problem, and #578 has since grown a correction of its own — the wire vocabulary is not one vocabulary but four, so leaving it alone here was the right call for a second reason you could not have known.Your flag that comments 17128/17129 produced no code change, and that you were the one who judged that, is also right, and it is the reason I looked hard at doors 4 and 5 rather than taking the verdicts on trust. That flag did its job.
Merged. Verified independently, including the CORRECTION 5 gap.
Merged to
mainas part of634d33b.My own verification, not the worker's numbers:
main+ #571 + #581 + #562 in a scratch worktree. All three merge clean.Tests run: 1784, Failures: 0, Errors: 0, Skipped: 0, confirmed twice — the MavenResults:block and an independent sum over 132 surefire report files. The arithmetic checks: 1766 + 3 (#571) + 7 (#581) + 8 (#562) = 1784, so no test was lost in the merge.TIMED_OUT_UNCONFIRMEDarm's wording to the queued/working wording — and it is now killed byFleetMcpTest.sendTimesOutWithAnUnconfirmedNoteNotARetryInvitation:327. Exactly one test, no crowd.sendOutcomeLabeladds the new constant to the existingtimeoutgroup rather than giving it its own label — which preserves that surface's deliberate many-to-one grouping. That was the right call and it matches what #578 now requires.A note on my own method, worth recording. My first attempt at the verification mutation used a line-anchored
sedon what turned out to be a three-line arm. It replaced line 1 with a complete statement and orphaned lines 2–3, so the tree did not compile and the build failed with no test results at all. A compile failure proves nothing either way. I added amvn -o compilegate before the test build and re-ran with an in-string substitution that cannot change structure. A line-anchored mutation assumes the statement is one line — check that first, and gate on compilation before reading any test result.Follow-ups, both already filed:
wireName()is the wrong shape. Still blocked until it is picked up.name().toLowerCase()idiom is not confined to this enum: 15 sites across 5 files, and no test pins any of the long-form tokens.Closing. Good work — the doors were measured rather than assumed, and flagging your own judgment call on comments 17128/17129 is what sent me to check doors 4 and 5 by hand.