A member is deregistered while still alive: release() removes the roster entry, then shells out to git before stopping the pane, so the resolver's spawned-member check goes blind for that window #702
Closed
opened 2026-10-04 01:28:49 +02:00 by ltms
·
4 comments
No Branch/Tag Specified
main
worker/702-4f5c7f-2
worker/715-5c43fc-1
worker/721-70f9ea-5
worker/718-99362b-2
worker/task-15-af0d10-12
worker/task-16-50a702-13
worker/task-12-4d0479-9
worker/task-13-823ce2-10
worker/705-ticket-owner-af9928-8
worker/703-list-collaborators-9c06c2-7
worker/669-example-truth-0b303d-6
worker/669-collab-deliverability-9ba859-3
worker/669-collab-reload-report-2a21bd-4
worker/669-7e80a6-1
worker/669-unit-d-efbbd7-1
worker/669-1b786a-1
worker/669-1d1d9f-1
worker/692-4afb9d-2
worker/689-02fced-13
worker/693-cf23fa-14
worker/677-fix-lead-collision-f69073-12
worker/638-fix-overmask-dbb1bf-11
worker/675-5b7478-4
worker/669-unit-a-70cc8f-3
worker/677-8cdaaf-5
worker/638-a7b391-1
worker/683-4536d6-2
worker/651-a75bbe-8
worker/680-20607d-7
worker/664-c12e95-3
worker/668-08534d-4
worker/672-0f2469-2
worker/670-7d1022-1
worker/661-ac7c28-2
worker/664-37fb9b-3
worker/663-remove-3arg-read-3f6783-1
worker/659-remove-dead-backcompat-ba5e6f-1
worker/637-revision-60a488-23
worker/656-redact-regression-tests-892903-19
worker/637-context-gauge-threshold-466eb5-16
worker/639-redact-line-numbers-de4ac4-17
worker/641-set-reformat-guard-6f96a4-18
worker/642-herdr-guard-scope-5de0e4-15
worker/650-javadoc-scope-95f3b3-14
worker/612-01e9f7-13
worker/612-a-r4-quarantine-outage-7ab0e8-5
worker/612-a-r9-r11-capacity-coverage-peers-cfcc79-7
worker/612-a-r10-loophealth-ccc872-8
worker/612-a-r12-turnregistrar-9e3bb7-9
worker/612-a-r5-leadconfigdir-9e70cf-6
lead/config-edit-redact-anchor-wording
worker/config-edit-seam-ca8dc1-1
worker/612-r67-630-lifecycle-290b8d-3
worker/629-625-ports-seams-da7d5d-4
worker/612-r12-exhaustion-f37cd7-1
worker/612-r38-amqp-24b083-2
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#702
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?
Found by a reviewer on PR #701 (#669 Unit D), dimension "resolver wiring and roster freshness". I confirmed the sequence and the intervening work myself in the main clone at
b92a669. Filed separately rather than folded into Unit D, for the reasons at the end.The sequence
SessionManager.release(String, ReleaseCause)removes the registry entry first:The pane is stopped inside
releaseRemoved, and only after this work:worktrees.hasUncommitted(removed.worktree())(:341), which shells out togit status. That is not my inference — the code says so at:363: "CB-581: hasUncommitted shells out togit statusand can throw on a non-zero exit."trySnapshot(removed, cause)(:360), a second git operation that writes a WIP ref.The comment at
:387confirms the stop is last: "the pane must always stop, even if the dirty check above threw."So between "no longer in the roster" and "process gone" there are one or two git subprocesses. The member is alive and can issue requests for that whole window.
Why it matters after #669 Unit D
Unit D adds a step to
CallerResolver.resolve(): a terminal belonging to a live spawned member resolves as that member's own role, consulting no tab map. That step reads a function built fromSessionManager.roster().A releasing-but-still-live member is absent from that roster. So during the window its terminal looks unknown, the resolver falls through to the lead and collaborator tab maps, and a pane whose tab carries a lead or collaborator label resolves as
PRIMARYorCOLLABORATORinstead ofWORKER— which is exactly the tab-label override Unit D exists to stop.It fails in the unsafe direction. "Not in the roster" is read as "not a member", when here it means "a member on its way out".
Severity, stated honestly
This is not a regression. Before Unit D the resolver had no roster step at all, so every member fell through to the tab maps on every request; the hole was total. Unit D narrows it to the release window. The residual window is a narrower instance of the old hole, not new damage.
The precondition does not hold on the mac host today. Reaching it needs a member pane carrying a configured lead or collaborator tab label. Startup validation refuses that combination (
FleetConfig.validatePanePlacementAgainstLeadTabs, plus the exact/template collision check added by #669 Unit B), and I checked the livefleetd/fleetd.yamlmyself: no profile usesplacement: pane. So this is latent here.It becomes live on any host that uses pane placement, and fleet01 is the direction where a second herdr daemon and different placement are plausible. I have not read fleet01's config and cannot.
What a fix looks like
Keep a terminal classifiable as a member until the pane is actually gone. Either hold a "releasing" marker that
spawnedMemberRolealso consults and that is cleared only afterlauncher.stopreturns, or stop the pane before deregistering and leave the worktree work after it.The ordering is not free either way: the current order exists so the handle and the registry entry are dropped together (
:331, fleetd #209), and reordering teardown is how teardown bugs get made. Whichever is chosen, the property to pin is: for every instant betweenrelease()being entered and the pane being gone, resolving that terminal does not consult a tab map.That property is testable without timing. Inject a
hasUncommittedthat resolves the terminal while it is being asked whether the worktree is dirty — that is a real call inside the window, so no sleep and no race are needed to land in it.Not verified by me
releaseby the same path, so the window may exist on more than one route.MemberPresencehas the same blind spot during release.placement: pane. I verified only the mac host's config.Decision — settled by an architect (profile
opus) at6e06058. I accept it, and it corrected a premise of mine. Ready to delegate; scheduled after #721 and before #705'sOBSERVERfloor.What I had wrong
I went in believing #702 and #705's presence split were one mechanism, on the grounds that this ticket's own last bullet wonders whether
MemberPresencehas the same blind spot during release. It does not. Presence has no writer on the release path at all:SessionManager.java:306SessionManager.java:338git statussubprocessSessionManager.java:341SessionManager.java:360SessionManager.java:390The only wiring of
MemberPresence.forgetinmainisFleetdAssembly.java:376, as the Injector'sforgetconsumer, firing atInjector.java:676and:845.grep -n "presence" session/SessionManager.javareturns a field, its construction, theasPresence()accessor and two javadoc lines —releasenever touches it.PresenceFleetoverrides onlymarkPresent, notforget.So the defect runs the other way: a dead pane stays marked present until the Injector tries to deliver and fails. That self-heals, and it fails toward one wasted delivery attempt, not a lockout. That closes this ticket's open bullet: no, and it does not need to be fixed here.
Two mechanisms, one shared premise
The shared premise is real: the registry is not a reliable answer to "is this pane a live member?" But the two differ where it counts:
CallerResolver.resolve)Fleetd.java:240)That last row decides it. One construct cannot cover both unless it is persisted, so designing them together would force persistence into a fix that does not need any. Ship #702 alone.
Also worth recording: the
OBSERVERfloor does not fix this. The floor is the last rung,CallerResolver.java:326, while the lead map is checked at:302, the architect map at:310and the collaborator map at:319— all before it. This ticket's whole harm is a tab map winning, so the floor changes nothing here.Two facts this ticket did not have
1. There are two registry-removal sites, not one. This answers another of the ticket's own open bullets — "I did not check whether the reaper and the shutdown drain reach
releaseby the same path". They do not. The idle reaper has its own CAS remove atSessionManager.java:317(releaseIfCurrent). A fix installed only at:306leaves the reaper's window wide open. Put the write in one shared private helper and call it from both. Four entry routes reach the window::288,:980(the context cap insidecompleteTurn),:1031(the reaper),:1167(the shutdown drain).2. A plain
Setis the wrong data structure. Two threads can be tearing down one pane at once — the CAS at:317exists because that race is real, and the loser then runsreleaseRemovedwithremoved == nulland still callslauncher.stopat:390. WithSet.add/Set.remove, the loser'sfinallyunmarks the terminal while the winner is still inside itsgit status, reopening the exact window the fix closes. Use a depth count:ConcurrentHashMap<String, Releasing>updated withcompute, key dropped only at depth zero.The mechanism
:306and:317. Before, not after — "for every instant" is false otherwise.spawnedMemberRolefunction, today an inline lambda atFleetdAssembly.java:485that streamssessions.roster(). It becomes aSessionManagermethod, so "in the registry OR being released" is computed in one place.finallywrapping removal through the end ofreleaseRemoved— not a line afterlauncher.stop.PeerLauncher.stop(peer/PeerLauncher.java:338) returnsvoidand declares nothing, so an unchecked throw is possible. With afinallythe marker cannot outlive the method whateverstopdoes. Written as a trailing line instead, the terminal stays marked a live member for the daemon's life, and the resolver then returnsPrincipal.worker(...)for it ignoring every tab map — a demotion, so a confusing lockout rather than a security hole, and cleared by any restart.The alternative, rejected on a count. Keeping the session in the registry with a new
RELEASINGstate is attractive —registry.replace's CAS preserves the atomic claim against double teardown that makesregistry.remove-first load-bearing. Butgrep -rn "\.roster()\|sessions::roster"gives 23 hits, 18 distinct read sites: the metrics gauges (FleetMetrics.java:89,:102),FleetHealthMonitor,LeadHeartbeatLoop, spawn capacity (FleetdAssembly.java:223),fleet_list(FleetMcp.java:1417). ARELEASINGsession would hold a spawn seat and show up infleet_list. That is a behaviour change at ~18 sites to fix a bug at one. The separate marker changes one reader.SessionManageralone —CallerResolverdoes not changeIts field javadoc already describes exactly this contract (
CallerResolver.java:78-83): "A function rather than the roster itself … the lookup strategy is the caller's to choose." The resolver asks; the manager answers. Teaching the auth layer about teardown is the wrong direction.It also fixes a second thing for free:
FleetdAssembly.java:485is an inline lambda, the untested-wiring shape #589 swept for. A method reference can be pinned by a wiring test, and it drops aroster()list copy plus a stream from the per-request hot path. That is a second concern — keep it visible in the diff, do not fold it in silently.The test property, and the trap in stating it
This ticket's test idea holds up and should be reused: inject a
hasUncommittedthat resolves the releasing terminal from inside the window. That is a real call inside the window, so no sleep and no race.Two things the brief must say. The control is mandatory — the lead tab map must contain that exact terminal, and the same resolve outside the window must return the lead role. Without it the test passes on an empty map and proves nothing.
And state the property as "no tab map is consulted", never "the same role is returned". Inside the window an architect legitimately resolves
WORKER, becausememberLifecycle.releasedat:338unbinds the slot before the git work (MemberRegistry.java:405-409) andCallerResolver.java:294reads that same map. A test written the second way fails, and the tempting fix is to move:338, which would be wrong. That is the kind of acceptance criterion that turns into a defect, and I have caused that nine times, so it goes in the brief verbatim.Ordering
#721 → #702 → the
OBSERVERfloor. #702 before the floor is a real argument, not tidiness: afterwards a releasing member never reaches the floor at all, so the floor applies only to genuinely unknown panes, which is the set it is for.If the floor went first, during every release window an unlabelled member pane would resolve
OBSERVERinstead ofWORKERand silently loseTASK_READ, and an architect would loseSEND, for one or two git subprocesses.REPLY/ASKsurvive —Authz.java:135names no role — so the member can still end its turn. Low harm, but a narrowing nobody asked for and invisible.Documentation
By the project's own table this touches no
fleet_*tool, noAuthz, noConnectionIdentity, no launcher and no injector gating. So it is an internal contract change →wiki/9-Implementation.md, and no11-Features.mdentry. I will confirm that against the final diff rather than now.What the architect did not do
It ran no build and no tests, so nothing it says about which tests go red is measured — I will re-run the mutation myself before merging. It did not time the window. It did not check whether herdr reuses a
terminal_id; if it does, a marker from an old teardown could shadow a new spawn, which the depth count makes a narrow race rather than a leak, but that is unverified. It did not re-verify #721.It did go one step further than this ticket on the latency question, and I am recording it because it closes a door:
validateLeadTabPrefixes(FleetConfig.java:2722) andvalidatePanePlacementAgainstLeadTabs(:2891) have no direct call site and run through the reflective sweepinvokeAllValidators(:3166) behindvalidateAll()(:3150), called at bothFleetd.java:201(startup) andConfigRef.java:416(every hot reload). So a hot reload cannot sneak in a colliding tab label. It read the livefleetd/fleetd.yaml(39368 bytes, mtime Oct 3 21:54) and confirmed every profile isplacement: tabwith nocollaborators:block — so the precondition stays latent on this host, and that is now true across reloads, not only at boot.Still unmeasured, and the ticket already says so: whether any other host uses
placement: pane. I have asked thefleet01lead directly, since neither I nor an architect here can read their config.Lead verification of PR #724, and one reviewer finding adjudicated
My own verification — on the merge, not the branch
mainmoved toed4f4b0after the branch was cut, so the merge tree (54feb485…) differs from the branch tree (6ea93fb0…). The implementer's green build therefore does not cover the merge, so I built the merge myself in a throwaway worktree.mvn -o clean install: exit 0, Tests run: 2031, Failures: 0, Errors: 0, Skipped: 0, BUILD SUCCESS.Three mutations of my own, aimed at the parts the implementer's tests claim to protect. Each compiled green first, so each was live rather than a compile error, and the file was restored byte-identical afterwards (
git diffempty).Releasing.leave()→ alwaysnull, i.e. plain-SetbehaviourspawnedMemberRoleSurvivesAnOverlappingReleaseThatUnmarksEarlyenter()'s preservation of the prior terminalCallerResolverTest.aPaneMidTeardownResolvesAsItsOwnRoleConsultingNoTabMapM3's kill goes through the production method reference, so it is behavioural evidence rather than a unit-level one. The depth count is genuinely load-bearing — M1 confirms a plain
Setwould be wrong.One weakness worth naming: M1 and M2 are two distinct invariants and both are pinned by the same single test. That is a thin pin. If
spawnedMemberRoleSurvivesAnOverlappingReleaseThatUnmarksEarlyis ever weakened, both go unprotected at once and nothing else notices.I also checked the claim that both removal sites are covered, rather than taking the shared helper's existence as proof. There are exactly two
registry.removesites,:320and:333, both now insidereleaseWindow.registry.replaceat:1328is a state transition and opens no window. The four entry routes are the publicrelease,:1064(the context cap incompleteTurn),:1115(the reaper) and:1251(the shutdown drain).findByTerminalis null-safe at:1309, so the later null guard inspawnedMemberRoleis not redundant — it is what protects the loop'sequals.Reviewer finding — rejected on the security claim, accepted as a test gap
A reviewer reported, at high severity, that the in-window test covers only the
devrole and so misses "a regression where a releasing ARCHITECT stays ARCHITECT instead of becoming WORKER, which grants wrong permissions during teardown".The escalation does not exist. I read the path:
releaseRemoved:422callsmemberLifecycle.released(removed.terminalId()), which isMemberRegistry.released(auth/MemberRegistry.java:405-409) →unbind(slot, terminal).worktrees.hasUncommitted(...)at:425, the git subprocess that creates the window.CallerResolver:288-298seesspawnedRole == ARCHITECT, looks the terminal up inarchitectTerminals, getsnull, and returnsPrincipal.worker(...).The roster's ARCHITECT answer is confirmed against the live slot map (the fleetd #424 defence), and that map is already unbound. A releasing architect resolves
WORKER. The new path cannot escalate, because the check it might have shadowed sits inside the same branch it added to.But the interaction is untested, and that part is fair. Nothing currently pins that a releasing architect is demoted. That coupling is security-relevant and a later change could break it silently, so I want one test for it — stating the property as "the architect slot map is unbound, so the result is
WORKER", never as role-equality.Not a merge blocker: the behaviour is correct today. I am asking for the test before merge because it is cheap and the implementer still holds the context.
Second reviewer: no defect in the production code. One javadoc line to fix, which I will do at merge.
A second reviewer took the production files on four concurrency points and reported no issue. It checked the depth count most closely, and its reasoning is the right reasoning: each
enterand eachleaveis one atomicConcurrentHashMap.computeon the same key, so each exit reads the live depth rather than a value captured when that thread entered. It walked three-thread interleavings with exits in a different order from entries (1→2→3→2→1→null) and they hold.It also confirmed two things I had not checked myself, and I accept them because it named the mechanism rather than just the verdict:
releaseIfCurrentstill marks and unmarks even though it never reachesreleaseRemoved, so it cannot clear the marker while a sibling is inside its ownlauncher.stop;release()that findsremoved == nullstill callslauncher.stop, and its marker stays up until its ownfinally.The javadoc line
Both that reviewer and I, reading independently, found the same imprecision. The
Releasingrecord's javadoc says:That attribution is wrong.
releaseIfCurrentis called only from the reaper, which passes a non-nullexpected, soknownis never null on that path. Theknown == nullcase happens inrelease(), whenregistry.get(paneId)already returns null.The code's null handling is correct either way — only the comment names the wrong caller. It matters because this repo's rules say a comment describes the code as it is, and a comment that claims an invariant is a free test case. A future reader trying to find the null-
knownpath would look in the wrong method.I am fixing this myself at merge, not asking the implementer. I have already told it "test files only" for its current turn, and sending a contradicting instruction now would reach a busy member anyway — which this project has measured does not work. The edit is one sentence of prose in a file I am about to merge.
Where this leaves the PR
Production code: verified by me, no defect from either reviewer, three of my own mutations killed. The only outstanding work is the two test additions I asked for in the follow-up — the releasing-architect demotion test, and splitting the single test that currently pins two separate invariants.
Lead verification and merge
Merged into
mainas38f4fd6, with one review commit of my own on top,8d3f10d. Pushed(
3fc39b9..8d3f10d).The merge needed its own build. Merge tree
338f5c16…was not the branch tree1412e4cc…, becausemainmoved under this branch (#715 landed first). The worker's green builddid not cover the merge, so I built it.
Merge build:
mvn -o clean install, Maven exit 0,BUILD SUCCESS,Tests run: 2047, Failures: 0, Errors: 0, Skipped: 0(2040 onmain+ 7 new).Mutations, on the merge
leave()→return null(a plainSetinstead of a depth count)releasingLeaveStepsDownADepthGreaterThanOneInsteadOfRemovingIt— 1 failure, that test onlyenter()drops the prior terminal when it has no session of its ownreleasingEnterPreservesThePriorTerminalWhenTheOverlappingCallHasNoSessionOfItsOwn— 1 failure, that test onlyspawnedMemberRolestops consulting the releasing mapSessionManagerTestunit pin andaPaneMidTeardownResolvesAsItsOwnRoleConsultingNoTabMap(expected: <WORKER> but was: <PRIMARY>— the lead tab won)MemberRegistry.releasedno longer unbinds the architect slotaReleasingArchitectIsDemotedToWorkerInsideTheTeardownWindow(expected: <WORKER> but was: <ARCHITECT>), plusonlyArchitectsBindAndReleaseMakesTheirSlotReusableM1 and M2 each fail exactly one test. That is the thing I asked for: the two invariants were
previously pinned by a single assertion, and they are now separable. M4 confirms the architect test
really depends on the unbind rather than on anything incidental, and the second red is extra
coverage I did not know was there.
My review commit,
8d3f10dTwo things, both in
SessionManagerand its test.The
Releasingjavadoc named the wrong caller. It said a losingreleaseIfCurrentCAS is thecall that arrives with no
knownsession.releaseIfCurrentis only called from the reaper, alwayswith a non-null
expected, so it always has one. The call that really passesnullis anoverlapping
releasethat finds the registry entry already gone. The same wrong claim had beencopied into a test's failure message, where whoever hits the failure would read it, so I fixed both.
Reflection replaced with a compile-time binding. The two split tests reached
Releasing.enterand
leavethrough sixsetAccessiblehelpers. The worker's reasoning for going white-box isright, and I checked it:
releaseWindowis called withregistry.get(paneId), so any nestedreleaseon the same pane necessarily has anullknown, which entangles the depth invariant andthe terminal-preservation invariant in one black-box scenario. But the test is in the same package,
so dropping
privatefrom the record and its two methods gets the same isolation with none of thereflection. Six helpers deleted, and a rename now breaks the build instead of a test run. The record
stays nested and non-public, so nothing outside
dev.ltms.fleet.sessioncan see it.I re-ran the baseline and all four mutations after that refactor, not before — the numbers above
are from the refactored tree. The tree I pushed is byte-identical
(
4826b35e77a62f29a00d2356c2e957193e3ca91d) to the one I built, so the 2047-test run covers exactlywhat is on
main.One thing the worker found that is worth keeping
Worktrees.hasUncommittedfires twice perrelease()— the pre-stop check anddirtyImmediatelyBeforeRemoval's post-stop re-check (fleetd #316). Both land inside the teardownwindow, so capturing either is valid for these tests, but anyone writing a future test that hooks
hasUncommittedshould know it is not a single-shot probe.A
wiki/9-Implementation.mdentry follows in a separate commit.