From 80092ff359ae9d2bfce819b61d51f4558b33ae4e Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Thu, 3 Sep 2026 12:41:20 +0700 Subject: [PATCH] fleetd #247: stop writing a trust key Claude Code strips on every save seedTrustDialog wrote two keys into the shared .claude.json: hasTrustDialogAccepted and hasCompletedProjectOnboarding. Only the first one survives. Measured live on 2026-09-03, minutes after a spawn seeded the file: hasTrustDialogAccepted: 28 of 28 project entries hasCompletedProjectOnboarding: 0 of 28 project entries Our entry was written by the running jar and the key was already gone, so it was written and then removed. It is absent from the 27 entries Claude Code wrote for itself too, which says Claude Code normalises the whole file when it saves and drops that key every time. That reframes #247. I filed it as a race - a save landing between our read and our ATOMIC_MOVE. It is not a race. The other writer removes this key as its steady-state behaviour, with no window involved. So the compare-and-swap retry proposed there would not have helped: it would re-add a key that gets stripped again on the next save. The seed's whole job is to stop the workspace-trust dialog blocking a member (#149). The live probe reached idle with hasTrustDialogAccepted alone, so the second key was never doing that job. Writing it only added a contested key to a file two processes share, and made the next reader think it mattered. The atomic write and the lock stay. Both are still correct, both are cheap, and hasTrustDialogAccepted is genuinely shared state. The new assertion is assertFalse, not a deletion. Removing the old assertion would leave nothing to stop someone re-adding the key later as a plausible-looking completeness fix. Mutation-tested: restoring the production line fails seedTrustDialogWritesOnlyTheTrustFlagAndNotTheOnboardingKey:2198 with 0 compile errors. 1229 tests, 0 failures. --- .../dev/ltms/fleet/member/ClaudeCodeLauncher.java | 14 ++++++++++++-- .../ltms/fleet/member/ClaudeCodeLauncherTest.java | 14 +++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java index 652b28d..e1ff6e6 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java @@ -495,7 +495,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { *

Additive, not a rewrite. {@code .claude.json} is large (tens of KB, dozens of * projects) and Claude Code itself rewrites it while running, so this reads the file as a JSON * tree (missing or unreadable → treated as an empty object) and changes only - * {@code projects..hasTrustDialogAccepted} / {@code .hasCompletedProjectOnboarding} — + * {@code projects..hasTrustDialogAccepted} (that key alone — see fleetd #247) — * every other top-level key and every other project entry is written back untouched. Only the * one project entry for {@code cwd} is replaced/created; an existing entry for a DIFFERENT cwd * (or the operator's own project history) is never touched. @@ -558,8 +558,18 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { if (!(projectNode instanceof ObjectNode)) { projects.set(cwd, project); } + // fleetd #247: ONLY hasTrustDialogAccepted. We used to write + // hasCompletedProjectOnboarding beside it; do not put it back. Measured on + // 2026-09-03, minutes after a live spawn seeded this file: 28 of 28 project + // entries carried hasTrustDialogAccepted and 0 of 28 carried the onboarding key + // — including the 27 entries Claude Code wrote for itself. Claude Code + // normalises the whole file when it saves and drops that key every time, so + // writing it achieved nothing except making the next reader think it mattered. + // The member reached idle with the trust flag alone, which is the only outcome + // this seed exists for. If a future Claude Code needs the second flag the + // symptom returns as the trust dialog fleetd #149 describes — re-measure then, + // do not restore it on a guess. project.put("hasTrustDialogAccepted", true); - project.put("hasCompletedProjectOnboarding", true); writeAtomically(target, TRUST_JSON.writerWithDefaultPrettyPrinter().writeValueAsString(root)); } catch (Exception e) { log.debug("cannot seed workspace-trust entry for cwd '{}' into '{}'", cwd, target, e); diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java index ea5f991..d995775 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java @@ -2159,8 +2159,7 @@ class ClaudeCodeLauncherTest { } JsonNode root = new ObjectMapper().readTree(claudeJson.toFile()); JsonNode project = root.path("projects").path(worktree.toString()); - seededBeforeStart.set(project.path("hasTrustDialogAccepted").asBoolean(false) - && project.path("hasCompletedProjectOnboarding").asBoolean(false)); + seededBeforeStart.set(project.path("hasTrustDialogAccepted").asBoolean(false)); } catch (IOException e) { seededBeforeStart.set(false); } @@ -2178,7 +2177,7 @@ class ClaudeCodeLauncherTest { } @Test - void seedTrustDialogWritesBothTrustFlagsForTheResolvedCwd( + void seedTrustDialogWritesOnlyTheTrustFlagAndNotTheOnboardingKey( @TempDir Path configDir, @TempDir Path worktree) throws Exception { markAsProvisionedWorktree(worktree); FakeHerdr herdr = new FakeHerdr(); @@ -2192,7 +2191,13 @@ class ClaudeCodeLauncherTest { JsonNode project = new ObjectMapper().readTree(claudeJson.toFile()) .path("projects").path(worktree.toString()); assertTrue(project.path("hasTrustDialogAccepted").asBoolean(false)); - assertTrue(project.path("hasCompletedProjectOnboarding").asBoolean(false)); + // fleetd #247: the onboarding key must NOT be written. Claude Code strips it on every + // save (measured: 0 of 28 live entries had it, including its own), so writing it only + // adds a contested key to a file two processes share. This assertion is the guard that + // stops it coming back as a plausible-looking "completeness" fix. + assertFalse(project.has("hasCompletedProjectOnboarding"), + "hasCompletedProjectOnboarding must not be written — Claude Code drops it on " + + "every save, and the member reaches idle on hasTrustDialogAccepted alone"); } /** @@ -2238,7 +2243,6 @@ class ClaudeCodeLauncherTest { JsonNode mine = root.path("projects").path(worktree.toString()); assertTrue(mine.path("hasTrustDialogAccepted").asBoolean(false)); - assertTrue(mine.path("hasCompletedProjectOnboarding").asBoolean(false)); } /**