fleetd #149: seed the workspace-trust dialog before a claude-code spawn #244
Closed
agent
wants to merge 0 commits from
worker/cb149-trust-dialog-2392a5-9 into main
pull from: worker/cb149-trust-dialog-2392a5-9
merge into: fleet:main
fleet:main
fleet:worker/fleetd-612-unita-87807e-1
fleet:worker/612-b3-mcpwirings-da2b58-3
fleet:worker/612-b2-cb185-176d3a-2
fleet:worker/612-b1-completion-457459-1
fleet:worker/612-agaps-73a926-2
fleet:worker/608-sleeps-3a64ff-3
fleet:worker/621-b4520b-1
fleet:worker/618-b83894-2
fleet:worker/fleetd-615-e05481-5
fleet:worker/lead-autocompact-5f1ab2-3
fleet:worker/fleetd-613-f85deb-3
fleet:worker/fleetd-608-flaky-nudge-test-d0c2d1-3
fleet:worker/lead-context-gauge-ad404f-1
fleet:worker/gauge-wiring-9158c1-4
fleet:worker/redeploy-slowstart-ead0e5-5
fleet:worker/charter-bytes-13668c-6
fleet:worker/rollover-outcome-291483-2
fleet:worker/589-f64303-2
fleet:worker/593-1a8025-5
fleet:worker/589-fcd2aa-1
fleet:worker/568-9fdaa2-3
fleet:worker/571-attempted-outcome-5739f7-2
fleet:worker/581-completionresolver-cas-sites-0542b7-6
fleet:worker/562-loop-health-wiring-test-99611c-5
fleet:worker/562-surface-loop-health-7df5cc-4
fleet:worker/575-waiter-cleanup-sites-62ad80-1
fleet:worker/572-answer-lock-release-46a9ae-5
fleet:worker/567-probe-channel-leak-a38fc5-6
fleet:worker/551-record-before-send-7cbf56-1
fleet:worker/561-listener-fanout-survives-a-throw-61d538-2
fleet:worker/555-redeploy-main-flow-seam-65c2f5-2
fleet:worker/556-injector-owns-registration-e027a5-1
fleet:worker/552-post-restart-mktemp-abort-bc2672-4
fleet:worker/553-onstatus-completion-leak-0da881-2
fleet:worker/550-shasum-linux-196132-1
fleet:worker/538-loop-dies-on-error-4a5eeb-6
fleet:worker/426-health-coverage-ef1fd4-4
fleet:worker/504-failed-reported-clean-3cfd66-3
fleet:worker/537-capturedlog-close-e4c437-2
fleet:worker/459-broken-link-targets-cadc17-5
fleet:worker/535-appender-leak-fe74c1-1
fleet:worker/512-part2-shutdown-detection-434701-9
fleet:worker/529-logger-level-sweep-2a5533-8
fleet:worker/528-drain-gate-call-site-5de83d-7
fleet:charter/forge-mcp-vs-token
fleet:worker/521-swap-guard-unpinned-28e931-5
fleet:worker/519-probe-test-harness-d25ab8-4
fleet:worker/525-logger-level-leak-1b4eb0-6
fleet:worker/518-fleetmcp-resolver-wiring-8ef96c-1
fleet:worker/512-drain-complete-line-7edd71-3
fleet:worker/517-abort-branch-and-jar-id-41b641-2
fleet:worker/500-9e52c9-3
fleet:worker/509-4912f4-2
fleet:worker/511-9a4b23-1
fleet:worker/493-479f45-2
fleet:worker/505-03f8b2-1
fleet:worker/492-followup-detect-unclear
fleet:worker/501-a31fa0-7
fleet:worker/498-451d1c-5
fleet:worker/494-1015ce-2
fleet:worker/492-209647-1
fleet:worker/489-001902-2
fleet:worker/480-relative-handover-path-906323-1
fleet:worker/480-b-handover-skill-45bf1f-5
fleet:worker/474-followup-source-pin-f54a55-17
fleet:worker/474-charter-check-on-reload-f54a55-17
fleet:worker/466-quarantine-repeatcount-report
fleet:worker/393-opencode-skill-seeding-71854b-13
fleet:worker/469-canonical-tool-names-2a472a-16
fleet:worker/466-quarantine-escalation-5ae9c1-15
fleet:worker/446-hot-exhausted-pattern-0af580-6
fleet:worker/464-charter-tool-name-guard-a85635-12
fleet:worker/463-listfleet-default-fails-open-f1c76c-11
fleet:worker/458-invariant-5-by-purpose-862f9a-10
fleet:worker/439-coordinator-row-gate-bc032a-8
fleet:worker/449-herdr-protocol-576015-4
fleet:worker/450-abstract-spawn-599e1c-5
fleet:worker/437-ack-refuses-177d91-1
fleet:worker/444-placement-window-feb56a-2
fleet:worker/440-helddurable-derived-d462d7-13
fleet:worker/425-rework-placement-resolve-c58ba1-9
fleet:worker/421-lead-peek-held-msgs-cdbad2-10
fleet:worker/435-fixed-policy-cap-fe11de-12
fleet:worker/422-gate-state-observability-9e79d6-11
fleet:worker/431-memberregistry-live-readers-cdbad2-10
fleet:worker/424-architect-slot-hot-038b41-7
fleet:worker/422-model-gate-spawn-c29f48-6
fleet:worker/425-default-profile-live-f55534-8
fleet:worker/415-coverage-wording-2cbf9c-5
fleet:worker/416-3ad1da-1
fleet:worker/418-588283-3
fleet:worker/deterministic-stamp-race-409-3cb7b6-10
fleet:worker/armed-reads-live-config-404-ed931f-9
fleet:worker/reply-peer-refusal-391-5a34bd-7
fleet:worker/models-allowlist-aa9e9b-3
fleet:worker/ttl-stamp-race-399-f1122f-8
fleet:worker/scrub-receipt-400-316b3e-5
fleet:worker/exhaustion-detection-395-105105-6
fleet:worker/scrub-abort-394-316b3e-5
fleet:fix/scrub-uid-abort
fleet:worker/task-scrub-517574-2
fleet:worker/t386-clock-bd5b78-4
fleet:worker/t384-scrub-813790-5
fleet:worker/t381-cc-748314-2
fleet:worker/t373-336973-2
fleet:worker/t365-3920c5-3
fleet:worker/t358-6e989b-1
fleet:worker/t355-8b321c-1
fleet:worker/fleetd-369-hermetic-git-tests-e8b19a-3
fleet:worker/fleetd-368-stale-lead-binding-f5682e-2
fleet:worker/fleetd-360-deploy-units-0d3793-1
fleet:worker/359-dead-lead-tabs-f1253b-4
fleet:worker/362-worktree-skills-c03e51-3
fleet:worker/361-coord-visibility-655144-1
fleet:362-plugin-visibility-and-drift
fleet:worker/errscan-bed2ca-2
fleet:worker/amqp-log-identity-bed2ca-2
fleet:worker/withdefaults-guard-561704
fleet:worker/sleepguard-82076d-1
fleet:worker/fd334-9ee1b6-5
fleet:worker/fd348-f1ab27-4
fleet:worker/fd335-a71c35-1
fleet:worker/fd342-174a17-2
fleet:worker/fd345-490d0f-3
fleet:worker/fleetd-337-5ec7d4-21
fleet:worker/fleetd-341-af5a6b-24
fleet:worker/fleetd-339-5ca0a2-23
fleet:worker/fleetd-338-83a4a1-22
fleet:worker/fleetd-333-281f46-18
fleet:worker/fleetd-329-11bdbb-16
fleet:worker/fleetd-330-2770fb-17
fleet:worker/fix-326-50506e-15
fleet:worker/fix-324-3e9bbf-14
fleet:worker/fix-323-b8287d-13
fleet:worker/fix-316b-bd0860-11
fleet:worker/fix-318-76ca36-9
fleet:worker/fix-317-486aec-8
fleet:worker/fix-315-ce47c5-6
fleet:worker/fix-307-275890-6
fleet:worker/fix-308-b4f664-7
fleet:worker/fix-309-ec3939-8
fleet:worker/fix-310-7a3974-9
fleet:worker/fix-302-52ad0e-9
fleet:worker/fix-298-ce1acb-8
fleet:worker/fix-297-66bd11-7
fleet:worker/fix-296-104622-6
fleet:worker/fix-293-bare-closetab-eb22b5-3
fleet:worker/fix-280-gone-ask-lapse-bca98e-2
fleet:worker/fix-290-reapidle-guard-coverage-9b0dd1-1
fleet:worker/fix-285-trust-seed-8f3565-10
fleet:worker/fix-284-backend-error-seat-85912c-11
fleet:worker/fix-282-chained-ask-e6d0bb-8
fleet:worker/fix-283-teardown-leaks-f40dfa-9
fleet:worker/fix-281-pin-handler-actions-4921ac-7
fleet:worker/audit-rendezvous-lifecycle-d072ae-2
fleet:worker/audit-health-placement-1a2476-6
fleet:worker/audit-teardown-exits-e207a5-3
fleet:worker/audit-launcher-asymmetry-27e370-4
fleet:worker/audit-rest-authz-6ca53c-5
fleet:worker/investigate-275-abandon-asking-fdef52-8
fleet:worker/fix-274-worktree-leak-b0095d-7
fleet:worker/fix-273-exhausted-pattern-9665b5-6
fleet:worker/fleetd-267-model-check-bd8068-1
fleet:worker/fleetd-131-archunit-18b834-7
fleet:worker/fleetd-266-sshagent-rename-a014ff-6
fleet:worker/fleetd-184-uid-claim-8e1f31-4
fleet:worker/fleetd-184-warn-b381ee-10
fleet:worker/fleetd-184-docs-be1d12-9
fleet:worker/fleetd-257-9bf010-7
fleet:worker/fleetd-103-23a113-6
fleet:worker/fleetd-247-342356-5
fleet:worker/fleetd-116-04dea8-4
fleet:worker/fleetd-252-a830e0-3
fleet:worker/fleetd-111-7e8673-9
fleet:worker/fleetd-155c-f8ef4b-8
fleet:worker/fleetd-176-b928ca-3
fleet:worker/fleetd-249-7a7878-2
fleet:worker/cb248-composition-root-b-9acdf7-15
fleet:worker/cb148-envrc-default-fa6c82-12
fleet:worker/cb201-unit5-wiring-6c12e6-8
fleet:worker/cb241-fallback-echo-1175e9-11
fleet:worker/cb134-148-overlay-visible-c9b986-10
fleet:worker/cb234-session-id-keyed-04e1fc-1
fleet:worker/cb201-unit3-nudge-abdf5c-6
fleet:worker/cb201-unit2-policy-c1102c-5
fleet:worker/cb201-unit4-outcome-a13bfa-7
fleet:worker/cb201-unit1-classifier-91b9b1-4
fleet:worker/cb201-227-refine-831980-3
fleet:worker/cb175-model-readback-0f085f-1
fleet:worker/cb222-charter-tmpdir-17f013-1
fleet:worker/cb226-architect-slot-race-cd3aa8-3
fleet:worker/cb224-worktree-root-group-024523-2
fleet:worker/cb-123-role-demotion-c600f7-2
fleet:worker/cb-219-opencode-roots-1f677e-1
fleet:worker/cb214-claude-session-id-b9eab4-4
fleet:worker/cb213-zdotdir-wrong-process-dd6de4-3
fleet:worker/cb211-exhaustion-classification-9546e0-2
fleet:worker/cb137-ambiguous-task-4df3d8-4
fleet:worker/cb209-agentsessionid-4dfdb6-2
fleet:worker/cb185-hostenvnames-2692b5-3
fleet:worker/cb206-opencode-sqlite-128718-2
fleet:worker/cb185-worktree-group-fc0c99-1
fleet:worker/cb-137-ask-ticket-e7760c-2
fleet:worker/cb-172-broker-uri-d36ae4-4
fleet:worker/cb-175-model-readback-76ead6-3
fleet:worker/cb-161-pane-ancestry-293510-1
fleet:worker/cb-164-rebase-885863-8
fleet:worker/cb-164-empty-scrape-false-success-1a80af-3
fleet:fix/cb-197-ticket-ttl-from-completion
fleet:worker/cb-189-remote-url-coverage-4692f3-1
fleet:worker/cb-185-blockers-027756-4
fleet:worker/cb-192-gap-log-11b631-2
fleet:worker/cb-633-fix-5f4396-3
fleet:worker/cb185-router-d6436d-3
fleet:worker/cb185-router-routing-gaps-9e9d33-3
fleet:worker/cb185-paneids-992586-2
fleet:worker/cb-633-allow-list-union-ed374b-1
fleet:worker/cb-157-credential-in-remote-url-496e44-2
fleet:worker/cb-641-health-herdr-evidence-8f1f54-6
fleet:worker/cb-640-health-msg-evidence-99c9cd-1
fleet:worker/cb-642-fleets-status-skill-bbbc40-5
fleet:cb-634-ide-mcp
fleet:worker/lead-comms-wiring-c014b9-7
fleet:worker/lead-mailbox-c19577-6
fleet:worker/autocompact-window-82bc2f-5
fleet:worker/cb-634-probe-18056f-4
fleet:worker/cb635-broker-urienv
fleet:worker/cb-632-config-retry-8e0efa-7
fleet:lead/cb-622e-claude-md
fleet:lead/cb-622-followup
fleet:worker/cb-622a-165dff-1
fleet:lead/cb-622d-opencode-mount
fleet:worker/cb-622b-717c67-2
fleet:worker/cb-622c-ab7759-3
fleet:worker/cb-617b2-20ca4b-3
fleet:worker/cb-617a-5c2f4a-1
fleet:worker/cb596-4e49ef-3
fleet:worker/cb586-10500c-1
fleet:worker/cb-606-b9343a-25
fleet:worker/cb604-1445f8-24
fleet:worker/cb582-477374-21
fleet:worker/cb584-8c2281-22
fleet:worker/cb600-e6b9a9-20
fleet:worker/cb602-ce257f-19
fleet:worker/cb601-b42837-18
fleet:worker/cb598-6c7ba7-17
fleet:worker/cb599-740fe4-16
fleet:worker/cb597-282224-15
fleet:worker/cb590fix-185e9a-10
fleet:worker/cb528-recovery-race
fleet:worker/cb594-96bead-8
fleet:worker/cb590-916766-2
fleet:worker/cb527-997d99-3
fleet:worker/cb592-env-leak-3cbf9c-1
fleet:worker/cb588-async-ticket-nudge-3218f7-5
fleet:worker/cb578b-9dcb13-6
fleet:worker/cb581-d24826-5
fleet:worker/m2-u5-ef8c42-15
fleet:worker/cb578a-516499-2
fleet:worker/cb576-01a04b-17
fleet:worker/cb579-lead-tab-acba06-20
fleet:worker/cb580-terminal-health-ed6058-21
fleet:worker/cb577-f36fdc-18
fleet:worker/cb573b-3db06f-16
fleet:worker/cb568c-f36fdc-18
fleet:worker/cb568-drop-cause-c3ac1c
fleet:worker/cb575-cancelled-notification-c3ac1c
fleet:worker/m4-sol-a2cbec-3
fleet:worker/cb574-async-ask-c3ac1c
fleet:worker/cb573-health-model-8ca857-14
fleet:worker/cb572-unknown-target-7f2e35-13
fleet:worker/u4-700706-9
fleet:worker/u3-b9fcb6-6
fleet:worker/u2-ef5b68-4
fleet:worker/u1-469dce-1-clean
fleet:worker/u1-469dce-1
fleet:worker/cb-564-health-events-70cf7e-2
fleet:worker/cb-565-recycle-drops-role-98e58f-3
fleet:worker/cb-563-missing-reply-df2866-1
fleet:worker/cb-562-readiness-gate-silent-6c23c9-3
fleet:worker/cb-560-architect-presence-da8155-1
fleet:worker/cb-561-architect-silent-off-a71cab-2
fleet:worker/cb-548-bind-architect-slot-fe1b8c-1
fleet:worker/parity-overlay-settings-5fb711-1
fleet:secrets-central-store
fleet:cb-559-hot-key-correction
fleet:cb-557-fleet-role-pools
fleet:worker/cb-553-maxload-explicit-spawn-305ee3-6
fleet:worker/cb-551-idle-lead-heartbeat-f1633c-1
fleet:worker/cb-544-drain-preserves-worktree-925fad-3
fleet:worker/cb-552-docs-sync-1cb9cf-4
fleet:worker/cb-548-rendezvous-guard-rebased
fleet:worker/cb-548-rendezvous-guard-116b53-10
fleet:worker/cb-548-authz-v2-586df6-8
fleet:worker/cb-548-authz-264363-5
fleet:salvage/cb-528b-codex-home
fleet:salvage/cb-528a-codex-launcher
fleet:CB-518-primary-flow
fleet:feature/peer-launcher-spi
fleet:cb-103-injector
No Reviewers
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#244
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 "worker/cb149-trust-dialog-2392a5-9"
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?
fleetd #149 — seed the workspace-trust dialog before a claude-code spawn
Incident hit while building this fix (report this first, before the normal summary)
What happened. My first version of
seedTrustDialogwrote unconditionally tocwd's trustentry whenever
cwdwas non-blank, with no check on whethercwdwas a worktree fleetd itselfprovisioned. Many pre-existing tests in
ClaudeCodeLauncherTestbuild aFleetConfig.Profilewith
configDir=nulland nocwd. PerHerdrPeerLauncher.resolveCwd, that falls back to thereal
user.dir(this Maven module's actual directory), and withconfigDir=nullthe write targetfalls back the same way to the real
~/.claude.json.While running a deliberate mutation-testing pass (mutating
seedTrustDialogto skip reading theexisting file, to prove the "additive" acceptance criterion is covered), that mutation's read of
the real, pre-existing
~/.claude.jsonwas never actually read — the write went straight to afreshly created object — and the operator's real
~/.claude.jsonwas overwritten and shrunk from72581 bytes (dozens of settings, ~27 projects,
oauthAccount, etc.) down to 178 bytes containingonly my test's single seeded entry.
What I could not do. I located a Claude Code auto-backup at
~/.claude/backups/.claude.json.backup.1788234449856(72532 bytes, verified valid JSON viaPython — 79 keys, 17 projects,
oauthAccountpresent) and tried to copy it back over the realfile. That write was blocked by the Claude Code auto-mode classifier (path outside my
worktree). A follow-up read-only
ls -la ~/.claude.jsonwas also blocked. Per the classifier'sown instruction to stop rather than work around a denial, I stopped and did not try any other tool
to reach that path. I have not verified the real file's current state since, and I could not
restore it myself — this needs operator or lead action, most likely restoring from the backup
path above.
Permanent fix, not a patch. I added
isProvisionedWorktree(cwd)— reusing the exact signalwriteIdeOverlayalready used to know it's touching a fleetd-provisioned worktree (a.gitthatis a regular file holding a
gitdir:pointer, never a real checkout's.gitdirectory) — andgated
seedTrustDialogon it. This closes the vulnerability everywhere aClaudeCodeLauncherisconstructed and spawned with an unconfigured
cwd/configDir, not just in this file's tests. Igrepped the rest of the module's tests to confirm no other test path reaches a real,
.git-havingdirectory with
configDir=null.I verified the gate is load-bearing with a scoped mutation test (see mutation table below,
Mutation 4) — run with
-Dtestlimited to only the two tests that redirect their target to a@TempDirregardless of the gate, so this verification run could not itself touch a real file.Implementation
ClaudeCodeLauncher.buildLaunchnow callsseedTrustDialog(cfg.configDir(), spec.cwd())rightafter the existing
CLAUDE_CONFIG_DIR/git-token env setup —buildLaunchalways runs strictlybefore the herdr
agent.startcall, so the seed lands before the peer process itself starts.seedTrustDialogdoes an additive Jackson tree read-modify-write of<configDir or ~>/.claude.json, settingprojects.<cwd>.hasTrustDialogAccepted = trueandprojects.<cwd>.hasCompletedProjectOnboarding = true, preserving every other key (includingother projects' data) via explicit
instanceof ObjectNodechecks rather than Jackson'sambiguous
.with(String). Any I/O failure is swallowed and logged at debug — this must neverblock a spawn.
isProvisionedWorktree(cwd)(see incident above) — shared withwriteIdeOverlay,which used the same
.git-regular-file-vs-directory signature inline before this change.FakeHerdrgainedonAgentStart(Runnable), fired synchronously the instant anagent.startcall reaches the fake — i.e. the instant the real herdr daemon would start the process. Used to
assert the seed is already on disk at that exact point, not merely once
spawn()returns.Acceptance criteria
idlewith no prompt — notprovable from here. I have no way to spawn a live claude-code member from inside this
worktree; this was flagged as an expected gap in the ticket brief.
configDirwhen set, else default~/.claude.json— covered byseedTrustDialogWritesBothTrustFlagsForTheResolvedCwdandseedTrustDialogTargetsDefaultClaudeJsonWhenConfigDirIsUnset(both against@TempDir/aredirected
user.home, never the real file).seedTrustDialogIsAdditiveAndPreservesUnknownKeysAndOtherProjects.seedsWorkspaceTrustForTheCwdBeforeTheProcessStarts, using the newFakeHerdr.onAgentStarthook.
seedTrustDialog/isProvisionedWorktreeare only referenced fromClaudeCodeLauncher;OpenCodeLauncheris untouched (confirmed bygit diff --stat, listedbelow).
Mutation testing (production code only, tests unedited)
grep -cE 'COMPILATION ERROR|cannot find symbol')seedTrustDialog(...)call frombuildLaunchseedsWorkspaceTrustForTheCwdBeforeTheProcessStarts,seedTrustDialogWritesBothTrustFlagsForTheResolvedCwdhasTrustDialogAccepted/hasCompletedProjectOnboardingliteral tofalseseedTrustDialogWritesBothTrustFlagsForTheResolvedCwd,seedsWorkspaceTrustForTheCwdBeforeTheProcessStartsroot = TRUST_JSON.createObjectNode()unconditionally, skipping the existing-file read)seedTrustDialogIsAdditiveAndPreservesUnknownKeysAndOtherProjects— this is the mutation that caused the real-file incident above, because it was run without scoping-Dtestto only the additive testisProvisionedWorktree(cwd)gate at the top ofseedTrustDialogseedTrustDialogNeverWritesWhenCwdIsNotAProvisionedWorktree,seedTrustDialogNeverWritesToDefaultHomeWhenCwdIsNotAProvisionedWorktree— run scoped to only these two tests (both redirect to@TempDir/redirecteduser.home), specifically to avoid recreating the incidentEach mutation was reverted (restored from a full-file backup, confirmed via
diff -q) before thenext one and before the final build below.
Build
Ran in
fleetd/inside my worktree, full and unpiped:Baseline on
mainwas 1163 tests; this PR adds exactly the 6 new tests (1169 - 1163 = 6).Rejected fixes (per the ticket) — none used
-p/non-interactive,--dangerously-skip-permissions, andpermissions.additionalDirectoriesare not used anywhere in this diff.
Files changed
fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.javafleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.javafleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.javaCaveats for review
~/.claude.jsonincident above is the most important thing in this PR. Please have theoperator check/restore from
~/.claude/backups/.claude.json.backup.1788234449856(or the mostrecent backup, if a newer one exists) — I could not do this myself and have not re-verified the
file's current state since the classifier blocked me.
idlewith no prompt) is not provable from a worker; needs aprimary-side live dogfood spawn against a fresh worktree to fully close.
OpenCodeLauncherand any other future peer-launcherkind would need its own equivalent seeding if it ever grows the same trust-dialog behavior —
today only claude-code has it.
Review round 2 — the write must be atomic and lock-protected
The lead's review caught a real defect:
Files.writeString(target, content)truncates the targetin place before writing, so there was a window where
.claude.jsoncould be observed empty orhalf-written. Two production failure modes follow: a crash/kill mid-write leaves the file
truncated, and two concurrent claude-code spawns (normal on this daemon — several run in parallel
routinely) racing a naive read-modify-write can silently drop one spawn's entry.
What changed
writeAtomically(Path target, String content)(package-visible, was inline inseedTrustDialog): serialises to a sibling temp file in the same directory astarget(anatomic move is only guaranteed within one filesystem), then
Files.move(tmp, target, ATOMIC_MOVE, REPLACE_EXISTING). A reader now only ever observes thefully-old or fully-new file, never a torn one. Preserves
target's existing POSIX permissions(
.claude.jsonships0600) viacopyPosixPermissionsIfPresent; no-ops on a non-POSIXfilesystem rather than failing.
TRUST_JSON_LOCK: aprivate static final Object,synchronizedaroundseedTrustDialog's entire read-modify-write. This is the concurrency decision the lead asked meto justify: a single process-wide lock is enough because every claude-code spawn on this daemon
runs in one JVM, so it fully serialises them — no lost updates between two spawns started at
once. It explicitly does not protect against a second daemon process, or the operator's own
live Claude Code process, writing
.claude.jsonat the same instant; that case is whatwriteAtomicallycovers instead (each such writer still only ever sees a fully-old or fully-newfile). I did not add cross-process locking (a lockfile,
FileChannel.lock()) — the lead's ownframing ("probably enough here, since all spawns go through one daemon") matches what a
single-daemon deployment actually needs, and cross-process locking would need to coordinate with
Claude Code's own writer too, which is out of fleetd's control either way.
IOException/mutation failure here is logged at debugand swallowed; a member that cannot be seeded still spawns.
Tests added (4, on top of the existing 6)
seedTrustDialogPreservesALargeExistingFileWithoutCollapsing— a 30-project, several-KB fixturesurvives; asserted on the restored key set (all 30 other projects'
hasTrustDialogAcceptedand nested
mcpServersdata, plus unrelated top-level keys), not just that the result parses,and the file's byte size never drops below its pre-seed size.
concurrentSeedsForDifferentCwdsBothSurvive— two threads spawn for different cwds sharingone
configDir, lined up at aCountDownLatch(no sleep); both entries must be present in thefinal file.
seedTrustDialogPreservesExisting0600Permissions— pre-sets.claude.jsontorw-------,spawns, asserts the permissions are still exactly
rw-------afterward. Skips (viaassumeTrue, the same pattern this file already uses elsewhere) on a filesystem with no POSIXpermissions.
writeAtomicallyNeverExposesATornFileToAConcurrentReader— callswriteAtomicallydirectly(not through
spawn()), with a large (~20 MB) payload and a busy-poll reader thread runningconcurrently; asserts every sample the reader takes is exactly the old content or exactly the
new content, never anything else.
An honest correction on the mutation table's numbering
I want to flag something rather than let it pass quietly: the review asked for "test 2" (the
concurrent-different-cwds test) to go red when the atomic move is reverted to a plain
Files.writeString. It does not, and I want to explain why rather than force a result.TRUST_JSON_LOCKfully serialises everyseedTrustDialogcall this launcher itself makes — twothreads calling
spawn()concurrently can never actually interleave inside the synchronizedblock, atomic move or not. So test 2 as specified is a test of the lock, and passes under this
mutation regardless of whether the underlying write is atomic. I verified this empirically rather
than assuming it (see the mutation table below) — after reverting the atomic move, I ran the full
ClaudeCodeLauncherTestclass and confirmedconcurrentSeedsForDifferentCwdsBothSurvivestayedgreen.
What actually goes red under that mutation is test 4,
writeAtomicallyNeverExposesATornFileToAConcurrentReader— the one test that callswriteAtomicallydirectly, outside the lock, which is the only way to exercise atomicityindependent of the lock's own serialisation. That is also why I made
writeAtomicallypackage-visible rather than private: a test going only through
seedTrustDialog/spawn()couldnever observe a torn file regardless of whether the underlying write is atomic, because the lock
already prevents any two writes from ever overlapping in this process.
Mutation table, round 2 (production code only; each reverted from a full-file backup +
diff -qbefore the next; run against the whole
ClaudeCodeLauncherTestclass — safe now, every fixtureuses
@TempDir/a redirecteduser.home)writeAtomicallyto a plainFiles.writeString(target, content)(no temp file, noATOMIC_MOVE)writeAtomicallyNeverExposesATornFileToAConcurrentReader(torn read of length 0 — the reader caught the exact truncation window).concurrentSeedsForDifferentCwdsBothSurvivestayed green — it tests the lock, not atomicity, as explained above.(Mutations 1–4 from round 1 are unaffected by this round's changes and still hold; see the PR
description above for that table.)
Build (round 2)
Ran in
fleetd/, full and unpiped:My branch's merge-base with
origin/mainis still26bafe8(I have not rebased, per theimplementer skill and the lead's instruction) — main has moved to 1168 tests since, per the lead.
1173 is what I measured on this branch, base
26bafe8(1163 tests there) + 10 new tests totalacross both rounds (6 from round 1, 4 from round 2).
Files changed (round 2 diff)
fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.javafleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java(
FakeHerdr.javais unchanged this round — no new herdr-transport seam was needed.)Merged into
mainas2e5b63f(round 2 head743377d). Closing this PR — the branch is inmain, not dropped.How I checked it (lead, in an isolated worktree):
ATOMIC_MOVEwith a truncating writewriteAtomicallyNeverExposesATornFileToAConcurrentReader:2534("torn read of length 360448")copyPosixPermissionsIfPresentsynchronized→if (true))concurrentSeedsForDifferentCwdsBothSurvive:2433("worktree B's entry must survive the race")Post-merge build on
main: 1182 tests, 0 failures, 0 errors, BUILD SUCCESS, with a separate check that the compile-error count is 0.One honest gap.
copyPosixPermissionsIfPresenthas no test — its mutation stays green. I accepted it and did not send round 3, becauseFiles.createTempFileis 0600 on POSIX by default, so the "never world-readable" property holds even without that line. The line only preserves a non-default mode that someone set by hand. Anyone tightening this later should know the coverage is not there.Known remaining risk, filed separately. Our lock is in-process. It cannot reach the operator's own running Claude Code, so an external write to
~/.claude.jsonbetween our read and ourATOMIC_MOVEis still lost. That is a real race and it is not fixed here. I chose to merge and file it rather than block a third round on it.Round 1 was sent back because it used a plain
Files.writeString, which is not atomic. Round 2 fixed that.Pull request closed