REST is the weaker door, and it is the door a lead falls back to when MCP drops #297
Closed
opened 2026-09-04 07:02:57 +02:00 by ltms
·
1 comment
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#297
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?
Two independent read-only hunts — one scoped to MCP-vs-REST parity, one scoped to placement and the two outage states — converged on the same conclusion from different directions. I verified every claim below myself.
Neither gap causes a wrong spawn. REST spawn goes through the same
CompositePeerLaunchergate (FleetApp.java:454-458), so a refusal is still a refusal. These are visibility and contract gaps, not state defects, and I want that stated plainly rather than inflated.What makes them worth fixing is when they bite.
listMemberscarries this comment in its own body:So REST is weakest exactly when it is load-bearing: during a degradation, when the richer door is already gone.
Gap 1 — two read-only endpoints throw outside the JSON error contract
FleetApphas aherdrError(Context, HerdrException)helper (:718) and four handlers that use it (:270,:286,:539,:684).healthzcatches a herdr transport failure at:270and turns it into a clean503— so this file already treats "herdr is briefly unreachable" as an expected, mappable condition.These two do not:
And there is no global handler to catch them:
grep -rn "\.exception(" fleetd/src/main/javareturns nothing.HerdrExceptionis aRuntimeException, so Javalin's default handling takes over and the response leaves the{error, detail}envelope every other failure path in the file guarantees (400/401/403/404/409/502/503 all use it).Direction of harm: a client that parses
{error, detail}uniformly breaks, at the moment it is asking why the fleet looks unreachable. The MCP twin returns clean typed error text for the identical failure.Reachable path: herdr briefly unreachable — the exact condition
healthzexists to report.Gap 2 —
GET /profilesreports neither outage stateIts MCP sibling
FleetMcp.profiles(:1017-1050) reportsquarantinedandcoolingOff, each with the credential id and the remaining seconds.GET /memberslikewise carries no capacity block at all, wherefleet_list'scapacityView(:1229-1268) does.Direction of harm: a lead on the REST fallback during an outage window cannot tell "busy, frees in 40s" from "broken". It burns turns re-probing or escalates something that was about to clear on its own. It is never told a wrong thing — it is told nothing.
What is already correct — recorded so nobody re-litigates it
The placement hunt looked hard for the classic "one outage state checked, the sibling not" shape and did not find it. I spot-checked its reasoning and it holds:
PlacementPolicyUtil.available()andemptyException()both consult quarantine and cooling-off together, with mutually-exclusive bucketing.FixedPlacementPolicy.select()checks both states in all three places it needs to: the default check, the fallback loop, and the error-message builder.CompositePeerLauncher.spawn()enforces both on the explicit-profile branch (:332-334) and derives both sets once into onePlacementContexton the placement branch (:349-351).Fleetd.java:212-225and:639-648wire one sharedBackendQuarantineand one sharedBackendOutagePolicyinto both the enforcement path and the reporting path — so the "two copies that drift" shape is ruled out at the instance level, which is the right level.PlacementPolicyTest,BackendOutagePolicyTest,CompositePeerLauncherTestandFleetMcpTestcover both states alone, combined, and their precedence (quarantineWinsOverCoolingOffWhenBothAreActiveOnTheSameCredential,quarantinedAndCoolingOffIsNotDoubleCounted).That is a genuinely well-covered area. The defect is not in the states; it is that one front door never reports them.
Scope
HerdrExceptionforGET /agentsandGET /members, using the existingherdrErrorhelper. Do not invent a new error shape.GET /profiles, reading from the same shared instancesFleetMcpreads. Do not compute them a second time — the whole reason this area is currently sound is that there is one instance of each, and a second copy is how it stops being sound. If the wiring is not reachable fromFleetApptoday, say so in your report and describe what you would have to pass in; do not force it with a static or a new singleton.GET /memberscapacity block: do not add it. Report what it would take, in one paragraph, and stop. I want to decide the response shape myself before it becomes a compatibility surface —listMembersalready carries a deprecatedworkersalias because a key was renamed once, and I am not repeating that.Rules
FleetAppTestcurrently has no test that makesworkers.list()fail and then hitsGET /membersorGET /agents— a grep for quarantine/coolingOff acrossFleetAppTest,FleetAppTwoDaemonTestandFleetAppAuthTestreturns nothing. You are adding the first coverage here, so state plainly what you added.herdrErrorbefore writing anything.FleetMcp. It is the correct side.Shape check
When done, look for the same shape — a rule enforced on the MCP door and absent on the REST door — in
FleetApp.javaonly. Report each in one line and do not fix any of it. Two known ones you can skip, both already ruled out as non-defects:sendMessage's missingprofileTargetErrorcheck (it fails safely downstream, so the only cost is a vaguer message), andPrimaryRegistrynot being wired intoFleetApp(REST callers have no herdr-pane identity, so the mechanism does not apply — it is not a skipped rule).Merged as
f34361b, corrected in9debc0d. Real merge built green at 1307 tests.The wiring is right, and it is the part I checked hardest.
Fleetdnow namesquarantineSourceandoutageSourceonce and hands the same instances to bothFleetMcpandFleetApp. The one productionnew FleetApp(...)call site uses the new constructor, so nothing ships inert — worth stating explicitly, because a new dependency that silently defaults to a no-op is how a feature lands dead with a green build.The worker also respected the three-item split: it did not build the
GET /memberscapacity block, and said so rather than leaving me to notice.My correction — and the ticket is what caused the problem
The merged
GET /profileswas a character-for-character copy ofFleetMcp.profiles's loop, in a different file.That is the #284 shape one level up. Sharing the
QuarantineSource/OutageSourceinstances stops the two doors reading different facts. It does nothing to stop them reporting those facts differently: rename a row key, add a field, and the edit lands on one door and not the other. The two then disagree about a live outage — which is the exact failure #284 was.This was my instruction, not a worker mistake. The ticket said "read from the same shared instances" and "do not change
FleetMcp". Together those made copying the loop the only legal move. The worker followed both rules exactly and the result was a second copy. I should have asked for the extraction.Fixed by extracting
FleetMcp.profilesViewand calling it from both doors. Its javadoc records why shared inputs are necessary but not sufficient.My mutation — testing the coupling, not the fix
The worker proved the fix works by reverting each of the three changes and quoting the failures. That is the right proof for the feature. It is not a proof of the property my correction adds, so I mutated for that instead: I renamed a row key in the one shared builder and checked that both doors fail.
One edit, both doors. Before the correction that same edit would have left the REST test passing and the two doors silently disagreeing.
One thing I will not overclaim
That mutation also caught
capacityView(:1275,:1283), which emits the same two key names forfleet_list. I looked at whether to fold that in as well and decided not to: it is a different response shape — per-profile capacity rows, not the profiles map — so it is shared vocabulary rather than a duplicated rule, and its javadoc at:1222-1229already cross-referencesfleet_profilesand spells out where the two deliberately differ.The material change is that every site emitting these keys now lives in one file, adjacent. The REST copy was the dangerous one precisely because an editor of
FleetMcpwould never have seen it.Shape check — three more, filed as follow-up, not fixed
The worker found three further "MCP enforces it, REST skips it" instances and correctly left them alone. I have not verified these myself yet; recording them as reported:
POST /members(spawnMember) — nocatch (HerdrException)aroundsessions.acquire, whereFleetMcp.spawncatches and returns a clean error.DELETE /members/{paneId}(stopMember) — no try/catch at all aroundsessions.release, whereFleetMcp.stopcatches.POST /sessions/{id}/reply(replyMessage) — reads content with.asText("")and no required-check, so a missingcontentfield silently becomes an empty-string reply, whereFleetMcp.replyrejects null content with "content is required".The third is the interesting one and is not the same severity as the other two. The first two produce a bare 500 — ugly, visible, and already accepted elsewhere in this file with passing tests. The third writes a wrong value silently: an empty reply is indistinguishable from a member that genuinely answered with nothing. I will file that separately.