The dirty-worktree check runs before the worker is stopped, so work written during teardown is deleted with no preserve and no snapshot #316
Closed
opened 2026-09-04 08:48:06 +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#316
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 delegated hunter. I read the whole method and confirmed the structure, and I extended the consequence — the hunter reported half of it.
Not observed. Read the severity honestly
I have not seen this happen, and I cannot point at a lost file. What follows is a structural TOCTOU with a narrow window. It is worth fixing because the direction of harm is data loss and the fix is cheap, not because it is known to have cost us anything. Do not write it up as an incident.
The order
SessionManager.releaseRemoveddecides, stops, then deletes:dirtyis computed while the worker is still running and is then used, unchanged, to authorise agit worktree remove --forcethat runs after the worker is stopped. Nothing re-reads it.Why this is worse than "we lose the preserve"
The hunter reported the preserve. The same stale boolean also gates the snapshot:
So in the racing case the worktree is not preserved and not snapshotted into
refs/wip/*. CB-578 stage C exists precisely so that preserving on disk is not the only copy. Here both defences are gated on the same stale read, so they fail together. There is no third layer: the pane is gone, the registry entry is gone, and the directory is force-deleted.The path in
fleet_stop{paneId}(or the reaper, or a failed turn) callsrelease.hasUncommittedshells out togit statusand returns false.dirty = false,preserveWorktree = false, no snapshot.launcher.stop(paneId)closes the pane.worktrees.remove(...)runsgit worktree remove --force. The new file is gone.Direction of harm: data loss — the worst direction in this area, and the one CB-576 and CB-578 were both written to prevent.
Window: from
hasUncommittedreturning toremovestarting. That coversmemberLifecycle.released, theresolveAgentSessionIdcall, the release-listener notification, and the whole oflauncher.stop(a herdr RPC). Tens to hundreds of milliseconds, not microseconds.One thing I did not establish: whether
agents.close(paneId)guarantees the agent process is dead before it returns. I readHerdrPeerLauncher.stopand it issues the close and moves on; whether the child is reaped synchronously is inside herdr. If it is not, the window extends past step 5 and a write can land after the pane is closed. Check this before you decide where the re-check goes — say what you found rather than assuming either way.Why nothing else catches it
catch (RuntimeException e)around the dirty check already encodes the right instinct — "fail toward the safe answer and preserve it — deleting on a guess can destroy work that has no other copy". That is the correct rule; it just is not applied to a result that has gone stale rather than thrown.worktrees.removeis--forceby design and does not consult the tree's state.refs/wipsnapshot is gated on the samedirty, as above.What I want
Goal: the decision to delete a worktree must be based on the tree's state at the moment of deletion, with the worker no longer able to write to it.
Invariants:
ReleaseCause.SHUTDOWNstill preserves unconditionally. Do not make a shutdown depend on agit statusthat might now say clean.release. The pane must always stop — that is CB-581, and an orphaned pane is its own kind of harm. Whatever you add is best-effort in exactly the way the surrounding steps are.git statusto every release. This runs on every teardown of every member. If you re-check, re-check only on the path that is actually about to delete something.Candidate mechanism, as a candidate only: re-run
hasUncommittedafterlauncher.stopand immediately beforeworktrees.remove, and skip the removal if it now says dirty (logging it the way the CB-576 branch does). Decide it yourself and justify it.Two things to weigh, and I do not know the answers:
trySnapshotruns before the stop, which is a reasonable "capture it while we can". If the re-check finds new work after the stop, that work has no snapshot. Should the snapshot move after the stop, be repeated, or stay where it is? Argue it.A tested, reported deviation is a good outcome here.
Rules
hasUncommittedcall and dirty on the second must not be removed. A test double forWorktreesthat changes its answer between calls is the obvious seam — check whether one already exists before writing a new one.git worktree removeorgit worktree pruneagainst this repo or any worktree under/Users/dai.ha/LTMS/.bridged-worktrees/. Other workers are live in them right now and you would destroy their work. If you need to test real git behaviour, use a throwaway repo under/tmpand say in your report which parts of this repo's shape (submodule,--skip-worktreefile, per-worktree config) your throwaway does not cover.git stash— the stash is shared across every worktree here.git add -A. Never merge.cd fleetd && mvn clean installunpiped, and quote the realTests run:andBUILDlines. Never pipe maven throughtail/head, and never read$?after a pipe — after a pipe it is the pipe's last command's status, not Maven's.Shape check
When done, look in
SessionManager.javaonly for the same shape: a value read once and then used to authorise a destructive or irreversible action later in the same method, after something else has had a chance to change it. One line each, do not fix any of it.Merged as
65f98ba, with one follow-up commit8426c35. Pushed tomain.Build on
mainafter the merge:Tests run: 1334, Failures: 0, Errors: 0, Skipped: 0—BUILD SUCCESS. After my added test:Tests run: 1335, same result. Both unpiped.The worker answered both design questions well, and taught me something
I asked whether the snapshot should move after the stop. The answer is no, and the reason is an invariant I did not know when I wrote the ticket:
So moving the whole decide-snapshot-preserve sequence after the stop — my second candidate — would have forced that notification to either move too, losing the fast-fail, or fire with a
snapshotRefthat was never computed. That would trade a regression on the common path for a fix on the rare one. Keeping the pre-stop snapshot and adding a second one only on the late-dirty path is the right call.The same invariant answers question 2. I am satisfied the targeted re-check beats the reorder.
The herdr question was answered honestly and I want that on the record. The worker traced
HerdrPeerLauncher.stop→agents.close(paneId)→AgentControl.close→ one blockingpane.closeRPC, and then said plainly that whether the child is dead and reaped when that call returns is decided inside herdr, whose source is not in this checkout. So the re-check narrows the race to whatever gap remains afterpane.closereturns, plus the few milliseconds to the removal. It does not prove that gap is zero. That is the correct thing to report and it is better than a confident guess either way.My own mutations, and the gap one of them found
J — the late re-check still snapshots, but no longer preserves. Removed only
preserveWorktree = true;:Good — the preserve half is pinned on its own.
K — the failed re-check falls toward deleting. Changed
dirtyImmediatelyBeforeRemoval's catch fromreturn truetoreturn false:It passed. Nothing pinned it. That mutation turns the guard into a cause of exactly the data loss it was added to prevent — a worktree whose
git statusfails is force-deleted — and the whole suite stayed green.This was invariant 1 of this ticket, stated first and in bold: "Fail toward preserving. If the state cannot be determined, keep the worktree." The worker implemented it correctly. It just did not pin it, and I would not have known without running the mutation.
I fixed it myself in
8426c35rather than sending it back, because it is one test. The existingfailHasUncommittedWithseam throws on every call, which cannot express this case — the pre-stop read has to succeed and only the late read fail — so I added a call-indexed seam,failHasUncommittedOnCall(int call, RuntimeException e), and the testreleasePreservesAWorktreeWhoseLateRecheckCannotBeRead. Re-running mutation K now gives:The lesson for me, not for the worker. I wrote three invariants into this ticket and only demanded a test for the racing case. An invariant nobody can break in a test is not an invariant, it is a comment. When a ticket states a fail-safe rule, the ticket has to ask for the test that pins it — otherwise the next person to touch that
catchblock gets a green build.Accepted caveat
ReleaseDetail.snapshotRefstill lags in the flip-to-dirty case: the notification has already fired withsnapshotRef = null, so the late snapshot reaches the log and not the listener. Fixing that means firingonReleasetwice for one release. That is a bigger change than this ticket and the worker was right to document it rather than do it. Not filing it separately — the WARN line names the ref, and this path is rare by construction.Shape check
The worker reports
acquireWithWorktree's failure-cleanup branch (~572-593) readsrepoRoot/pathnear the top and reuses them on the exception path to authoriseworktrees.remove/deleteBranch. Same shape, lower risk: no worker has been started at that point, so there is nothing of anyone's to lose. Not filing.Closing.