diff --git a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java index f395ad4..22c828d 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -1253,7 +1253,11 @@ public final class FleetMcp { + "worktree: to provision an isolated git worktree. Pass resumeSessionId " + "to relaunch onto a prior conversation instead of starting cold — this requires an " + "explicit profile whose backend supports it (fleet_list shows agentSessionId for " - + "resumable members), and is refused otherwise rather than silently starting fresh. " + + "resumable members; it is absent for a member fleetd cannot reliably re-identify, " + + "e.g. an opencode member spawned without a worktree), and is refused otherwise " + + "rather than silently starting fresh. For an opencode profile, resumeSessionId " + + "itself also requires worktree:true/ on THIS spawn — without one fleetd can " + + "never re-verify which conversation it actually resumed (fleetd #249). " + "sessionName gives the member a display name in its own UI when the backend supports " + "one. Returns the member's sessionId (use with fleet_send) and paneId (use with " + "fleet_stop).", @@ -1264,7 +1268,7 @@ public final class FleetMcp { "worktree", Map.of("type", "string", "description", "'true' or a ticket slug — requests an isolated git worktree"), "ticket", stringProp("Ticket slug when worktree:true"), "sessionName", stringProp("Logical display name for the member's own session, when its backend supports one"), - "resumeSessionId", stringProp("A prior member's agentSessionId (from fleet_list) to resume — requires an explicit profile that supports it")), + "resumeSessionId", stringProp("A prior member's agentSessionId (from fleet_list) to resume — requires an explicit profile that supports it, and (for opencode) a worktree on this spawn too")), List.of())); } @@ -1285,9 +1289,14 @@ public final class FleetMcp { + "discover a peer lead without being told its address. 'members' are the " + "sessions delegated to — each with sessionId, paneId, role (architect/dev/" + "reviewer), profile (the backend it runs on), state, optional " - + "worktree/branch/owner/agentSessionId (the id to pass as fleet_spawn's " - + "resumeSessionId to relaunch onto that same conversation, when the backend " - + "supports it), and live herdr status. An empty 'members' " + + "worktree/branch/owner/agentSessionId, and live herdr status. agentSessionId, " + + "when present, is the id to pass as fleet_spawn's resumeSessionId to relaunch " + + "onto that same conversation. It is ABSENT — not a guess — for a member fleetd " + + "cannot reliably re-identify: some backends (e.g. opencode) resolve it from the " + + "member's working directory, which only uniquely identifies a member when it " + + "was spawned into its own fleetd-provisioned worktree (worktree:true/); a " + + "member spawned without one shares its directory with others and never reports " + + "an id, however long it runs (fleetd #249). An empty 'members' " + "means no members are spawned; it says nothing about peers. When capacity " + "facts are configured, a 'capacity' row per profile also reports free: 0 for " + "a quarantined profile's credential (see fleet_profiles), whatever its " 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 e1ff6e6..01855ae 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java @@ -505,7 +505,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { * peer that starts without the seed still starts; it just may hit the dialog fleetd #149 * describes. * - *

Gated to a provisioned worktree ({@link #isProvisionedWorktree}) — see that + *

Gated to a provisioned worktree ({@link HerdrPeerLauncher#isProvisionedWorktree}) — see that * method's javadoc for the incident that made this gate mandatory, not optional: this must * never run against a real checkout or an un-configured fallback cwd, only the exact * always-fresh-directory population fleetd #149 describes. @@ -516,7 +516,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { * sibling-temp-file + {@code ATOMIC_MOVE}, never a truncate-in-place) so a crash mid-write or a * concurrent reader never observes a half-written file, and through {@link #TRUST_JSON_LOCK} so * two concurrent spawns' entries both survive instead of the second write silently discarding - * the first. Both exist because of a real incident: see {@link #isProvisionedWorktree}'s javadoc + * the first. Both exist because of a real incident: see {@link HerdrPeerLauncher#isProvisionedWorktree}'s javadoc * and {@link #writeAtomically}'s javadoc. * * @param configDir the profile's {@code CLAUDE_CONFIG_DIR} ({@code cfg.configDir()}), or @@ -588,7 +588,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { *

fleetd #149 incident. The original implementation used * {@code Files.writeString(target, content)} directly, which truncates {@code target} in place * before writing the replacement bytes. Combined with an ungated {@code cwd} (see - * {@link #isProvisionedWorktree}'s javadoc), a mutation-testing run hit that truncation window + * {@link HerdrPeerLauncher#isProvisionedWorktree}'s javadoc), a mutation-testing run hit that truncation window * against the operator's real {@code ~/.claude.json} and left it at 178 bytes. The gate closes * which file this can ever target; this closes how the target is written, so * that even a legitimate write against a real, live, concurrently-read {@code .claude.json} @@ -638,32 +638,6 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { } } - /** - * Whether {@code cwd} is a fleetd-provisioned git worktree — signalled the same way - * {@link #writeIdeOverlay} already gates on: a {@code .git} that is a regular - * file holding a {@code gitdir:} pointer, as opposed to a real checkout's {@code .git} - * directory. {@code null}/blank never qualifies. - * - *

Shared by every write that must land only in a worktree fleetd itself created for a - * member — never in a real checkout, an arbitrary configured directory, or (see the incident - * below) the daemon's own fallback cwd. - * - *

fleetd #149 incident. {@link #seedTrustDialog} originally ran unconditionally on - * any non-blank {@code cwd}. Most of this launcher's OWN tests spawn a profile with no - * {@code cwd} configured, so the base class's {@code resolveCwd} falls through to the real - * {@code user.dir} — and with no {@code configDir} either (also the common case in this - * file's fixtures), the seed's target falls through the same way to the real - * {@code ~/.claude.json}. Running this repo's own test suite corrupted the operator's actual - * config file (it shrank from ~72 KB to a single seeded entry) the first time a mutation - * happened to make the write non-additive. Gating both cwd-targeted writes on "this is a - * worktree fleetd provisioned" — exactly the population fleetd #149 describes - * ({@code worktree: true} always lands in a brand-new directory) — makes that class of write - * impossible against a real checkout or an untouched fallback cwd, in production or in tests. - */ - private static boolean isProvisionedWorktree(String cwd) { - return cwd != null && !cwd.isBlank() && Files.isRegularFile(Path.of(cwd, ".git")); - } - /** {@code s}, or {@code null} when {@code s} is null/blank — the charter-presence test used above. */ private static String nonBlank(String s) { return (s == null || s.isBlank()) ? null : s; diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java index 75c5ef8..3ae40d7 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java @@ -358,6 +358,42 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { return Files.isRegularFile(candidate) ? candidate : null; } + /** + * Whether {@code cwd} is a fleetd-provisioned git worktree — signalled the same way + * {@code ClaudeCodeLauncher#writeIdeOverlay} already gates on: a {@code .git} that is a + * regular file holding a {@code gitdir:} pointer, as opposed to a real + * checkout's {@code .git} directory. {@code null}/blank never qualifies. + * + *

Shared by every write (and, since fleetd #249, every identity read) that must land only + * in a worktree fleetd itself created for a member — never in a real checkout, an arbitrary + * configured directory, or (see the incident below) the daemon's own fallback cwd. Package- + * private (not {@code protected}) on purpose: {@link ClaudeCodeLauncher} and + * {@link OpenCodeLauncher} both call it, and same-package visibility is enough — no subclass + * outside this package needs it. + * + *

fleetd #149 incident. {@code ClaudeCodeLauncher#seedTrustDialog} originally ran + * unconditionally on any non-blank {@code cwd}. Most of that launcher's OWN tests spawn a + * profile with no {@code cwd} configured, so the base class's {@code resolveCwd} falls + * through to the real {@code user.dir} — and with no {@code configDir} either (also the + * common case in that file's fixtures), the seed's target falls through the same way to the + * real {@code ~/.claude.json}. Running this repo's own test suite corrupted the operator's + * actual config file (it shrank from ~72 KB to a single seeded entry) the first time a + * mutation happened to make the write non-additive. Gating both cwd-targeted writes on "this + * is a worktree fleetd provisioned" — exactly the population fleetd #149 describes + * ({@code worktree: true} always lands in a brand-new directory) — makes that class of write + * impossible against a real checkout or an untouched fallback cwd, in production or in tests. + * + *

fleetd #249. The same reasoning extends to a READ: {@code + * OpenCodeSessionDiscovery#sessionIdForDirectory} keys on {@code directory}, a heuristic that + * is only reliable when the directory is unique to this member — i.e., exactly the population + * this gate identifies. {@link OpenCodeLauncher} uses it to withhold {@code agentSessionId()} + * (report absence rather than a guess) and to refuse a {@code resumeSessionId} spawn that + * cannot be resolved reliably going forward. + */ + static boolean isProvisionedWorktree(String cwd) { + return cwd != null && !cwd.isBlank() && Files.isRegularFile(Path.of(cwd, ".git")); + } + // --- profile surface ----------------------------------------------------------------------- /** The configured peer profile names (what {@code spawn(profile)} accepts). */ diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java index e5aaefc..badcfc6 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java @@ -672,13 +672,29 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { /** Add lazy on-disk session discovery to the base handle. */ @Override public PeerHandle spawn(SpawnRequest req) { + String cwd = effectiveCwd(req); + // fleetd #249: refuse rather than silently resume into unverifiable territory. opencode's + // `-s ` flag itself resumes precisely — the resolved id is what fails, not the resume — + // but resolvedSessionId() below can never confirm (or later re-report) this handle's own + // identity without a fleetd-provisioned worktree (isProvisionedWorktree(cwd)), because the + // directory is shared and sessionIdForDirectory's "most recently updated row" heuristic can + // pick a sibling's session. Refusing here, before anything spawns, beats letting the member + // start and only then discovering fleetd can never again verify who it actually is. + if (req.resumeSessionId() != null && !req.resumeSessionId().isBlank() + && !isProvisionedWorktree(cwd)) { + throw new IllegalArgumentException("resumeSessionId requires a fleetd-provisioned " + + "worktree for an opencode profile — without one, this member's cwd is shared " + + "with other sessions, so fleetd can never reliably confirm (now or later) which " + + "conversation it is actually running (fleetd #249). Pass fleet_spawn{worktree:" + + "} to resume this member."); + } PeerHandle inner = super.spawn(req); // fleetd #175: the same profile config buildLaunch resolved for this spawn (requireProfile // is deterministic on req.profileName(), so re-resolving here costs a map lookup, not a // second decision) — SessionAwareHandle needs cfg.model() to know what THIS session should // be running. FleetConfig.Profile cfg = requireProfile(req.profileName()); - return new SessionAwareHandle(inner, discovery, effectiveCwd(req), cfg, + return new SessionAwareHandle(inner, discovery, cwd, cfg, this::memberHerdrSocketConfigured, discoveryUnavailableWarned, exhaustionSink); } @@ -718,6 +734,17 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { * the directory right now." */ private final AtomicReference resolvedSessionId = new AtomicReference<>(); + /** + * fleetd #249: whether {@link #cwd} is a fleetd-provisioned git worktree + * ({@link HerdrPeerLauncher#isProvisionedWorktree}), computed once at spawn time since + * {@code cwd} never changes for this handle. When {@code false} the directory is shared + * with other sessions (the default no-worktree spawn inherits the lead's own cwd), so + * {@link OpenCodeSessionDiscovery#sessionIdForDirectory}'s "most recently updated row for + * this directory" heuristic can and does pick another session's row — see that class's + * javadoc. {@link #agentSessionId()} refuses to guess in that case: it reports absent + * rather than a possibly-foreign id. + */ + private final boolean worktreeProvisioned; SessionAwareHandle(PeerHandle delegate, OpenCodeSessionDiscovery discovery, String cwd, FleetConfig.Profile cfg, @@ -731,6 +758,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { this.discoveryUnavailable = discoveryUnavailable; this.discoveryUnavailableWarned = discoveryUnavailableWarned; this.exhaustionSink = exhaustionSink; + this.worktreeProvisioned = isProvisionedWorktree(cwd); } @Override @@ -760,6 +788,9 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { // built from (see OpenCodeLauncher#defaultDiscoveryRoot's javadoc for the full // reasoning). Scanning fleetd's own $HOME under that config would only ever find "no // row" and read as "resume unsupported" — declare it unavailable instead, once, loudly. + // Checked before the fleetd #249 worktree gate below: this OS-user mismatch makes + // discovery unusable regardless of whether cwd happens to be a provisioned worktree, so + // it earns the one-time WARN either way. if (discoveryUnavailable.getAsBoolean()) { if (discoveryUnavailableWarned.compareAndSet(false, true)) { log.warn("opencode session discovery unavailable: memberHerdrSocket is " @@ -771,6 +802,16 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { } return null; } + // fleetd #249: cwd is shared with other sessions unless fleetd itself provisioned this + // worktree, and sessionIdForDirectory's directory-keyed heuristic cannot tell this + // member's row apart from a sibling's in that case (measured: a three-day-old row from + // a different profile). Refuse to guess — absent is the honest answer, and it is what + // this codebase already returns elsewhere for absent evidence (fleetd #175's UNKNOWN). + // No WARN here: unlike discoveryUnavailable above, this is the ordinary, expected shape + // of the large majority of spawns (no worktree requested), not a configuration gap. + if (!worktreeProvisioned) { + return null; + } // fleetd #234: once resolved, stay resolved. Re-deriving from `directory` on every call // would let this handle's identity drift to a sibling session that later shares the // same cwd and writes a newer row — see resolvedSessionId's javadoc. diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java index 6fd791b..d10e98f 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java @@ -323,11 +323,39 @@ class OpenCodeLauncherTest { // --- CB-547: resume + post-hoc session discovery -------------------------------------------- + /** + * Give {@code dir} the exact signature {@link HerdrPeerLauncher#isProvisionedWorktree} checks + * for: a {@code .git} REGULAR FILE, never a directory. Content is never parsed by that gate, so + * any {@code gitdir:} pointer is fine. Mirrors {@code ClaudeCodeLauncherTest}'s helper of the + * same shape (fleetd #249). + */ + private static void markAsProvisionedWorktree(Path dir) throws IOException { + Files.writeString(dir.resolve(".git"), "gitdir: /tmp/not-a-real-gitdir"); + } + + /** + * A fresh subdirectory of {@code configRoot}, marked as a provisioned worktree (fleetd #249), + * for tests that predate this gate and stood in a bare {@code "/work/dir"} string as their + * member's cwd — a directory that never existed on disk and, post-#249, would never pass + * {@link HerdrPeerLauncher#isProvisionedWorktree} either. Those tests are about the model + * mismatch / late-resolve machinery (fleetd #175/#234/#209), not about the worktree gate + * itself, so they need a cwd the gate accepts without changing what each test demonstrates. + */ + private static String provisionedWorkDir(Path configRoot) throws IOException { + Path dir = Files.createDirectories(configRoot.resolve("work-dir")); + markAsProvisionedWorktree(dir); + return dir.toString(); + } + @Test - void aResumeSpawnPassesTheSessionIdAsDashS(@TempDir Path root) { + void aResumeSpawnIntoAProvisionedWorktreePassesTheSessionIdAsDashS(@TempDir Path root, + @TempDir Path worktree) + throws Exception { + markAsProvisionedWorktree(worktree); FakeHerdr herdr = new FakeHerdr(); service(herdr, root, opencodeCfg("google/gemini-2.5-pro", null, null)) - .spawn(new SpawnRequest(null, null, null, null, "ses_41b79fc90ffeI9E8uZv6VprUn2")); + .spawn(new SpawnRequest(null, worktree.toString(), null, null, + "ses_41b79fc90ffeI9E8uZv6VprUn2")); List args = startArgs(herdr); int s = args.indexOf("-s"); @@ -336,6 +364,27 @@ class OpenCodeLauncherTest { "the resume target id follows -s"); } + /** + * fleetd #249 acceptance criterion 3: without a fleetd-provisioned worktree, the member's cwd + * is shared with other sessions, so fleetd can never reliably confirm (now or later via {@link + * OpenCodeSessionDiscovery}) which conversation it is actually running. Refuse the spawn itself + * rather than silently launching opencode's {@code -s } into unverifiable territory. + */ + @Test + void aResumeSpawnWithoutAProvisionedWorktreeIsRefused(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + OpenCodeLauncher launcher = service(herdr, root, + opencodeCfg("google/gemini-2.5-pro", null, null)); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> + launcher.spawn(new SpawnRequest(null, null, null, null, + "ses_41b79fc90ffeI9E8uZv6VprUn2"))); + + assertTrue(e.getMessage().contains("worktree"), e.getMessage()); + assertFalse(herdr.called("agent.start"), + "the refusal must happen before anything spawns — no pane, no process"); + } + @Test void aFreshSpawnCarriesNoSessionFlag(@TempDir Path root) { FakeHerdr herdr = new FakeHerdr(); @@ -348,25 +397,59 @@ class OpenCodeLauncherTest { @Test void theHandleDiscoversTheSessionIdForTheWorkersCwdOnlyAfterItAppears(@TempDir Path root, - @TempDir Path discRoot) + @TempDir Path discRoot, + @TempDir Path worktree) throws Exception { + markAsProvisionedWorktree(worktree); FakeHerdr herdr = new FakeHerdr(); OpenCodeLauncher launcher = new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), Map.of("gemini", opencodeCfg(null, null, null)), "gemini", _ -> null, 0, System::currentTimeMillis, () -> { }, root, discRoot); - PeerHandle handle = launcher.spawn(new SpawnRequest(null, "/work/dir", null)); + PeerHandle handle = launcher.spawn(new SpawnRequest(null, worktree.toString(), null)); // opencode writes the record only when the session is first persisted — the instant the // pane is ready it does not exist, so agentSessionId() is null (never a spawn failure). assertNull(handle.agentSessionId(), "no record yet → null, not a spawn-time block"); // Once the record appears (here: same cwd), lazy discovery resolves it — the handle's // session id matches its own worktree, not another's. - OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_resolved", "/work/dir", 1000L); + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_resolved", worktree.toString(), 1000L); assertEquals("ses_resolved", handle.agentSessionId(), "agentSessionId() re-scans and picks up a record that has since been written"); } + /** + * fleetd #249 acceptance criterion 1, exercised through the real caller path (the handle + * {@code fleet_list} actually reads), not {@link OpenCodeSessionDiscovery} directly. Without a + * fleetd-provisioned worktree the member's cwd is shared — the default no-worktree spawn + * inherits the lead's own long-lived cwd — so even once a matching row appears (here: + * simulating another profile's session that happens to share the directory) the handle must + * report absence rather than guess. Measured real-world case (2026-09-03): the row it would + * otherwise pick was three days old and belonged to a different profile. + */ + @Test + void theHandleNeverReportsAnIdForANonProvisionedCwdEvenAfterARowAppears(@TempDir Path root, + @TempDir Path discRoot) + throws Exception { + FakeHerdr herdr = new FakeHerdr(); + OpenCodeLauncher launcher = new OpenCodeLauncher(new AgentControl(herdr), + new WorkspaceControl(herdr), Map.of("gemini", opencodeCfg(null, null, null)), + "gemini", _ -> null, 0, System::currentTimeMillis, () -> { }, root, discRoot); + // No markAsProvisionedWorktree — this cwd has no .git file, the shared-cwd shape a + // no-worktree spawn (or a real checkout) actually has. + String sharedCwd = root.resolve("shared-cwd").toString(); + + PeerHandle handle = launcher.spawn(new SpawnRequest(null, sharedCwd, null)); + + assertNull(handle.agentSessionId(), "no record yet → null, same as the provisioned case"); + // A row for this exact directory now appears — e.g. a sibling member, or a stale session + // from days earlier, sharing the same unprovisioned cwd. + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_someone_elses", sharedCwd, 1000L); + assertNull(handle.agentSessionId(), + "a non-provisioned cwd must NEVER report an id, even once a row for it exists — " + + "the row could belong to any other session sharing this directory"); + } + @Test void foreignWorkerMatchesOpencodePrefixButNotClaude() { String nonce = "abc123"; @@ -920,6 +1003,7 @@ class OpenCodeLauncherTest { @Test void theRealSessionManagerLateResolvePathCatchesAModelMismatch(@TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + String workDir = provisionedWorkDir(configRoot); FakeHerdr herdr = new FakeHerdr(); // xf's real shape (fleetd #175): weight:80, model "opencode/nemotron-3-ultra-free", no // credentialId — the profile that actually escaped the fleet's accounting. @@ -929,7 +1013,7 @@ class OpenCodeLauncherTest { OpenCodeLauncher launcher = serviceWithSink(herdr, configRoot, discRoot, cfg, sink); SessionManager sessions = new SessionManager(launcher); - MemberSession acquired = sessions.acquire(cfg.profile(), "/work/dir", null, null); + MemberSession acquired = sessions.acquire(cfg.profile(), workDir, null, null); // Real late-resolve path, driven BEFORE opencode has written its session row — same shape // as production the instant a pane goes ready. @@ -940,7 +1024,7 @@ class OpenCodeLauncherTest { // opencode writes its row late, running gpt-5.6-sol (a PAID credential) instead of the // withdrawn free model the profile actually asked for — the exact fleetd #175 scenario. - OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", "/work/dir", 1000L, + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", workDir, 1000L, "{\"id\":\"gpt-5.6-sol\",\"providerID\":\"openai\"}"); // Drive the SAME real late-resolve path again: sessions.get() -> resolveAgentSessionId -> @@ -959,12 +1043,13 @@ class OpenCodeLauncherTest { @Test void aProviderPrefixedModelMatchingBothIdAndProviderIsNotAMismatch(@TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + String workDir = provisionedWorkDir(configRoot); List exhausted = new ArrayList<>(); ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg("openai/gpt-5.6-terra", null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) - .spawn(new SpawnRequest(null, "/work/dir", null)); - OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", "/work/dir", 1000L, + .spawn(new SpawnRequest(null, workDir, null)); + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", workDir, 1000L, "{\"id\":\"gpt-5.6-terra\",\"providerID\":\"openai\"}"); assertEquals("ses_x", handle.agentSessionId()); @@ -975,12 +1060,13 @@ class OpenCodeLauncherTest { @Test void aGxProviderPrefixedModelMatchingBothIdAndProviderIsNotAMismatch(@TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + String workDir = provisionedWorkDir(configRoot); List exhausted = new ArrayList<>(); ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg("gx/deepseek-v4-flash", null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) - .spawn(new SpawnRequest(null, "/work/dir", null)); - OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", "/work/dir", 1000L, + .spawn(new SpawnRequest(null, workDir, null)); + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", workDir, 1000L, "{\"id\":\"deepseek-v4-flash\",\"providerID\":\"gx\"}"); assertEquals("ses_x", handle.agentSessionId()); @@ -997,12 +1083,13 @@ class OpenCodeLauncherTest { @Test void aMissingProviderIdInTheEvidenceIsUnknownNotAMismatchWhenTheIdMatches( @TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + String workDir = provisionedWorkDir(configRoot); List exhausted = new ArrayList<>(); ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg("openai/gpt-5.6-terra", null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) - .spawn(new SpawnRequest(null, "/work/dir", null)); - OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", "/work/dir", 1000L, + .spawn(new SpawnRequest(null, workDir, null)); + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", workDir, 1000L, "{\"id\":\"gpt-5.6-terra\"}"); assertEquals("ses_x", handle.agentSessionId()); @@ -1018,12 +1105,13 @@ class OpenCodeLauncherTest { @Test void aMissingProviderIdInTheEvidenceStillCatchesARealIdMismatch( @TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + String workDir = provisionedWorkDir(configRoot); List exhausted = new ArrayList<>(); ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg("openai/gpt-5.6-terra", null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) - .spawn(new SpawnRequest(null, "/work/dir", null)); - OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", "/work/dir", 1000L, + .spawn(new SpawnRequest(null, workDir, null)); + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", workDir, 1000L, "{\"id\":\"gpt-5.6-sol\"}"); assertEquals("ses_x", handle.agentSessionId()); @@ -1042,12 +1130,13 @@ class OpenCodeLauncherTest { @Test void aBareModelWithNoProviderPrefixMatchesOnIdAloneAndIsNotAMismatch(@TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + String workDir = provisionedWorkDir(configRoot); List exhausted = new ArrayList<>(); ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg("deepseek-v4-flash", null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) - .spawn(new SpawnRequest(null, "/work/dir", null)); - OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", "/work/dir", 1000L, + .spawn(new SpawnRequest(null, workDir, null)); + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", workDir, 1000L, "{\"id\":\"deepseek-v4-flash\",\"providerID\":\"gx\"}"); assertEquals("ses_x", handle.agentSessionId()); @@ -1064,6 +1153,7 @@ class OpenCodeLauncherTest { @Test void aRealIdMismatchLogsAnErrorNamingBothModelsAndQuarantinesThroughTheSink( @TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + String workDir = provisionedWorkDir(configRoot); List exhausted = new ArrayList<>(); ExhaustionSink sink = (target, reason, profile) -> exhausted.add(target + "|" + reason); FleetConfig.Profile cfg = opencodeCfg("opencode/nemotron-3-ultra-free", null, null); @@ -1075,8 +1165,8 @@ class OpenCodeLauncherTest { PeerHandle handle; try { handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) - .spawn(new SpawnRequest(null, "/work/dir", null)); - OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", "/work/dir", 1000L, + .spawn(new SpawnRequest(null, workDir, null)); + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", workDir, 1000L, "{\"id\":\"gpt-5.6-sol\",\"providerID\":\"openai\"}"); assertEquals("ses_x", handle.agentSessionId()); } finally { @@ -1111,11 +1201,12 @@ class OpenCodeLauncherTest { @Test void unknownOrUnparseableModelEvidenceNeverQuarantines(@TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + String workDir = provisionedWorkDir(configRoot); List exhausted = new ArrayList<>(); ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg("openai/gpt-5.6-terra", null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) - .spawn(new SpawnRequest(null, "/work/dir", null)); + .spawn(new SpawnRequest(null, workDir, null)); // No row yet at all. assertNull(handle.agentSessionId()); @@ -1137,12 +1228,13 @@ class OpenCodeLauncherTest { @Test void aProfileWithNoConfiguredModelIsNeverCheckedForAMismatch(@TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + String workDir = provisionedWorkDir(configRoot); List exhausted = new ArrayList<>(); ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg(null, null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) - .spawn(new SpawnRequest(null, "/work/dir", null)); - OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", "/work/dir", 1000L, + .spawn(new SpawnRequest(null, workDir, null)); + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", workDir, 1000L, "{\"id\":\"anything-at-all\",\"providerID\":\"anyone\"}"); assertEquals("ses_x", handle.agentSessionId()); @@ -1166,21 +1258,22 @@ class OpenCodeLauncherTest { @Test void modelCheckReadsTheResolvedSessionsOwnRowNotWhateverIsNewestInTheSharedDirectory( @TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + String workDir = provisionedWorkDir(configRoot); List exhausted = new ArrayList<>(); ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg("openai/gpt-5.6-terra", null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) - .spawn(new SpawnRequest(null, "/work/dir", null)); + .spawn(new SpawnRequest(null, workDir, null)); // Our own session's row, correctly matching the profile's requested model. - OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_ours", "/work/dir", 1000L, + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_ours", workDir, 1000L, "{\"id\":\"gpt-5.6-terra\",\"providerID\":\"openai\"}"); assertEquals("ses_ours", handle.agentSessionId(), "resolves to our own session"); assertTrue(exhausted.isEmpty(), "matching model → no mismatch on first resolve: " + exhausted); // A sibling member, spawned later into the SAME shared directory (no worktree, fleetd // #234's default), writes a newer row running a totally different model. - OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_sibling", "/work/dir", 9000L, + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_sibling", workDir, 9000L, "{\"id\":\"deepseek-v4-flash\",\"providerID\":\"gx\"}"); assertEquals("ses_ours", handle.agentSessionId(), @@ -1212,6 +1305,7 @@ class OpenCodeLauncherTest { @Test void aSpawnTimeModelMismatchActuallyQuarantinesTheCredentialThroughTheRealAcquirePath( @TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + String workDir = provisionedWorkDir(configRoot); FleetConfig.Profile cfg = opencodeCfgWithCredential( "terra", "opencode/nemotron-3-ultra-free", "openai-shared"); Map profiles = Map.of(cfg.profile(), cfg); @@ -1232,14 +1326,14 @@ class OpenCodeLauncherTest { // The mismatching row exists BEFORE the spawn — reproducing fleetd #234's exact timing: // opencode's session table already carries evidence by the moment acquire() first asks. - OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", "/work/dir", 1000L, + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", workDir, 1000L, "{\"id\":\"gpt-5.6-sol\",\"providerID\":\"openai\"}"); assertFalse(quarantine.isQuarantined("openai-shared"), "nothing quarantined before the spawn"); // The real production entrypoint: acquire() builds the MemberSession by calling // handle.agentSessionId() BEFORE registry.put() runs. - MemberSession acquired = sessions.acquire(cfg.profile(), "/work/dir", null, null); + MemberSession acquired = sessions.acquire(cfg.profile(), workDir, null, null); assertEquals("ses_x", acquired.agentSessionId(), "the id itself still resolves correctly"); assertTrue(quarantine.isQuarantined("openai-shared"), @@ -1258,6 +1352,7 @@ class OpenCodeLauncherTest { @Test void aRosterOnlySinkSilentlyDropsTheSpawnTimeQuarantine(@TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + String workDir = provisionedWorkDir(configRoot); FleetConfig.Profile cfg = opencodeCfgWithCredential( "terra", "opencode/nemotron-3-ultra-free", "openai-shared"); BackendQuarantine quarantine = new BackendQuarantine(() -> 0L, TimeUnit.SECONDS.toNanos(1800)); @@ -1270,10 +1365,10 @@ class OpenCodeLauncherTest { OpenCodeLauncher launcher = serviceWithSink(herdr, configRoot, discRoot, cfg, rosterOnlySink); SessionManager sessions = new SessionManager(launcher); - OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", "/work/dir", 1000L, + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", workDir, 1000L, "{\"id\":\"gpt-5.6-sol\",\"providerID\":\"openai\"}"); - MemberSession acquired = sessions.acquire(cfg.profile(), "/work/dir", null, null); + MemberSession acquired = sessions.acquire(cfg.profile(), workDir, null, null); assertEquals("ses_x", acquired.agentSessionId(), "the id itself still resolves correctly"); assertFalse(quarantine.isQuarantined("openai-shared"), @@ -1301,6 +1396,7 @@ class OpenCodeLauncherTest { @Test void theSpawnTimeQuarantineSurvivesTheFleetdStyleForwardingHop(@TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + String workDir = provisionedWorkDir(configRoot); FleetConfig.Profile cfg = opencodeCfgWithCredential( "terra", "opencode/nemotron-3-ultra-free", "openai-shared"); Map profiles = Map.of(cfg.profile(), cfg); @@ -1332,12 +1428,12 @@ class OpenCodeLauncherTest { }; exhaustionSinkRef.set(realSink); - OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", "/work/dir", 1000L, + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", workDir, 1000L, "{\"id\":\"gpt-5.6-sol\",\"providerID\":\"openai\"}"); assertFalse(quarantine.isQuarantined("openai-shared"), "nothing quarantined before the spawn"); - MemberSession acquired = sessions.acquire(cfg.profile(), "/work/dir", null, null); + MemberSession acquired = sessions.acquire(cfg.profile(), workDir, null, null); assertEquals("ses_x", acquired.agentSessionId(), "the id itself still resolves correctly"); assertTrue(quarantine.isQuarantined("openai-shared"),