From 2566538521db3ff8c2dbf8c3dc364c3d606ebf28 Mon Sep 17 00:00:00 2001 From: James3014 <185240413+James3014@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:57:34 +0800 Subject: [PATCH] fix(herdr): resolve durable handle binding regression and reconcile stale slots (#242) --- src/local-agent-herdr.test.ts | 43 +++++++++++++++++++++++++ src/local-agent-herdr.ts | 54 ++++++++++++++++++++++++++------ src/local-agent-sessions.test.ts | 7 +++++ src/local-agent-sessions.ts | 40 ++++++++++++++--------- src/local-agent-store.ts | 1 + src/roots.test.ts | 8 ++++- src/roots.ts | 15 ++++++++- 7 files changed, 142 insertions(+), 26 deletions(-) diff --git a/src/local-agent-herdr.test.ts b/src/local-agent-herdr.test.ts index 87eaf8c38..76e8344a5 100644 --- a/src/local-agent-herdr.test.ts +++ b/src/local-agent-herdr.test.ts @@ -1279,6 +1279,49 @@ test("HerdrThinGateway observed-launch absence proof is exact and identity-misma ); }); +test("HerdrThinGateway pre-agent launch absence proof covers FENCED, WORKSPACE_OBSERVED, OUTCOME_UNKNOWN", async () => { + const gateway = new SpyHerdrGateway("/tmp/herdr-preagent-absence.sock", new HerdrGatewayRegistry()); + gateway.pingServer = async () => true; + + const fencedLaunch = { + state: "FENCED", + launchRequestId: "HERDR-LAUNCH:issue256-fenced", + attemptKey: "issue256-fenced", + dispatchIntentHash: "a".repeat(64), + canonicalWorktreePath: "/tmp/issue256-worktree", + gitHeadBefore: "b".repeat(40), + agentKind: "opencode", + herdrSocketPath: "/tmp/herdr-preagent-absence.sock", + promptNonce: "nonce-issue256-fenced", + workspaceId: "ws-local-256", + plannedAgentName: "ds-planned-agent-1", + fencedAt: new Date().toISOString(), + } as any; + + (gateway as any).sendRequest = async (req: HerdrSocketRequest): Promise> => { + if (req.method === "agent.get") { + return { id: req.id, error: { code: "agent_not_found", message: "not found" } }; + } + if (req.method === "workspace.get") { + return { id: req.id, error: { code: "workspace_not_found", message: "not found" } }; + } + throw new Error(`Unexpected request ${req.method}`); + }; + + assert.equal(await gateway.confirmPreAgentLaunchAbsent(fencedLaunch), true); + + const observedWorkspaceLaunch = { + ...fencedLaunch, + state: "WORKSPACE_OBSERVED", + herdrWorkspaceId: "ws-preagent-1", + herdrPaneId: "pane-preagent-1", + }; + assert.equal(await gateway.confirmPreAgentLaunchAbsent(observedWorkspaceLaunch), true); + + gateway.pingServer = async () => false; + assert.equal(await gateway.confirmPreAgentLaunchAbsent(fencedLaunch), false); +}); + test("HerdrThinGateway prompt fence negative matrix and zero external calls (C1, PF-WRONG-ATTEMPT, PF-WRONG-DISPATCH, PF-WRONG-NONCE, PF-MISSING-HANDLE, PF-MALFORMED-HANDLE, PF-WRONG-RUNTIME, PF-CONCURRENT, PF-EXACT)", async () => { const stateDir = mkdtempSync(join(tmpdir(), "devspace-pf-matrix-")); const store = new LocalAgentStore(stateDir); diff --git a/src/local-agent-herdr.ts b/src/local-agent-herdr.ts index bdbb26313..e16e83c2f 100644 --- a/src/local-agent-herdr.ts +++ b/src/local-agent-herdr.ts @@ -12,7 +12,7 @@ import { OpencodeRuntime, } from "./local-agent-opencode.js"; import { AgentProviderFailureError } from "./local-agent-errors.js"; -import { canonicalizePath } from "./roots.js"; +import { canonicalizePath, isSameWorktreePath } from "./roots.js"; export const HERDR_DEFAULT_SOCKET_PATH = process.env.HERDR_SOCKET_PATH || "/Users/james/.config/herdr/herdr.sock"; export const HERDR_RUNTIME_KIND = "HERDR" as const; @@ -734,6 +734,7 @@ export class HerdrThinGateway { if (res.error) { const message = res.error.message.trim(); const exactNotFound = + res.error.code === "agent_not_found" || message === `agent target ${agentName} not found` || message === `agent '${agentName}' not found` || message === `agent ${agentName} not found`; @@ -760,7 +761,7 @@ export class HerdrThinGateway { if (res.error) { const message = res.error.message.trim(); const exactNotFound = - res.error.code === "workspace_not_found" && + res.error.code === "workspace_not_found" || message === `workspace ${workspaceId} not found`; if (exactNotFound) return true; throw new Error( @@ -818,7 +819,7 @@ export class HerdrThinGateway { ? canonicalizePath(liveObs.pane.foreground_cwd) : undefined; const expected = canonicalizePath(launch.canonicalWorktreePath); - return paneCwd === expected || paneForegroundCwd === expected; + return isSameWorktreePath(paneCwd, expected) || isSameWorktreePath(paneForegroundCwd, expected); })(); const missingExactAgent = exactPaneStillOwned && @@ -834,6 +835,41 @@ export class HerdrThinGateway { return this.confirmAgentAbsent(launch.herdrAgentIdentity, targetSocket); } + /** + * Verify external absence for pre-agent launches (FENCED, WORKSPACE_OBSERVED, OUTCOME_UNKNOWN). + * Verifies HerdR daemon connectivity and confirms that neither the planned agent nor orphaned + * workspace is active, cleaning up orphaned workspaces if safe. + */ + async confirmPreAgentLaunchAbsent(launch: ExternalRuntimeLaunchFence): Promise { + if (!launch.herdrSocketPath) { + throw new Error("[LAUNCH_ABSENCE_UNVERIFIED] Missing HerdR socket path."); + } + const targetSocket = normalizeHerdrSocketPath(launch.herdrSocketPath); + if (!await this.pingServer(2000, targetSocket)) { + return false; + } + + const agentName = launch.herdrAgentIdentity ?? launch.plannedAgentName; + if (agentName) { + const agentAbsent = await this.confirmAgentAbsent(agentName, targetSocket); + if (!agentAbsent) return false; + } + + if (launch.herdrWorkspaceId) { + const wsAbsent = await this.confirmWorkspaceAbsent(launch.herdrWorkspaceId, targetSocket); + if (!wsAbsent) { + try { + await this.closeWorkspace(launch.herdrWorkspaceId, targetSocket); + } catch { + // If close fails, re-verify absence below + } + return this.confirmWorkspaceAbsent(launch.herdrWorkspaceId, targetSocket); + } + } + + return true; + } + /** * Validate physical AgentInfo object against expected launch/target identity (E1, F4, Comment 5785928588). * Missing required fields (workspace_id, pane_id, name, cwd) or any contradiction fails closed. @@ -1295,7 +1331,7 @@ export class HerdrThinGateway { } } - if (canonicalizePath(record.workspaceRoot) !== canonicalPath) { + if (!isSameWorktreePath(record.workspaceRoot, canonicalPath)) { throw new Error( `[N4 Wrong Worktree] Record workspaceRoot '${record.workspaceRoot}' does not match canonical worktree '${canonicalPath}'`, ); @@ -1309,7 +1345,7 @@ export class HerdrThinGateway { `[N2 Conflicting Replay] attemptKey '${params.attemptKey}' already active with different intent hash '${existing.dispatchIntentHash}'`, ); } - if (canonicalizePath(existing.canonicalWorktreePath) !== canonicalPath) { + if (!isSameWorktreePath(existing.canonicalWorktreePath, canonicalPath)) { throw new Error( `[N4 Wrong Worktree] Observed cwd '${existing.canonicalWorktreePath}' does not match canonical worktree '${canonicalPath}'`, ); @@ -1373,7 +1409,7 @@ export class HerdrThinGateway { `[ATTEMPT_REPLAY_CONFLICT] Replay intent hash '${params.dispatchIntentHash}' does not match launch fence intent hash '${launch.dispatchIntentHash}'`, ); } - if (canonicalizePath(launch.canonicalWorktreePath) !== canonicalPath) { + if (!isSameWorktreePath(launch.canonicalWorktreePath, canonicalPath)) { throw new Error( `[N4 Wrong Worktree] Observed cwd '${launch.canonicalWorktreePath}' does not match canonical worktree '${canonicalPath}'`, ); @@ -1504,7 +1540,7 @@ export class HerdrThinGateway { const observedCwd = canonicalizePath(wsRes.result.root_pane.cwd); // N4: Wrong worktree fail closed - if (observedCwd !== canonicalPath) { + if (!isSameWorktreePath(observedCwd, canonicalPath)) { await this.closeWorkspace(wsId, herdrSocketPath).catch(() => {}); throw new Error( `[N4 Wrong Worktree] Observed cwd '${observedCwd}' does not match canonical worktree '${canonicalPath}'`, @@ -2390,8 +2426,8 @@ export class HerdrThinGateway { const paneForegroundCwd = liveObs.pane?.foreground_cwd ? canonicalizePath(liveObs.pane.foreground_cwd) : undefined; - return paneCwd === canonicalizePath(boundHandle.canonicalWorktreePath) - || paneForegroundCwd === canonicalizePath(boundHandle.canonicalWorktreePath); + return isSameWorktreePath(paneCwd, boundHandle.canonicalWorktreePath) + || isSameWorktreePath(paneForegroundCwd, boundHandle.canonicalWorktreePath); })(); const missingExactAgent = exactPaneStillOwned && diff --git a/src/local-agent-sessions.test.ts b/src/local-agent-sessions.test.ts index 0fef6f203..bddaac5b1 100644 --- a/src/local-agent-sessions.test.ts +++ b/src/local-agent-sessions.test.ts @@ -2118,6 +2118,13 @@ test("Issue #256: HerdR stale lifecycle and capacity reconciliation 5D matrix", return this.workspaceAbsent || this.agentAbsent; } + override async confirmPreAgentLaunchAbsent(): Promise { + if (!this.serverReachable || this.identityMismatch) { + throw new Error("Exact HerdR absence is unverified."); + } + return this.workspaceAbsent || this.agentAbsent; + } + override async startExternalAgent(params: any): Promise { const handle: HerdrExternalHandle = { schemaVersion: 1, diff --git a/src/local-agent-sessions.ts b/src/local-agent-sessions.ts index 670d2cb8a..f106692f2 100644 --- a/src/local-agent-sessions.ts +++ b/src/local-agent-sessions.ts @@ -59,7 +59,7 @@ import { isClineCatalogFresh, type ClineCatalogSnapshot } from "./local-agent-cl import type { ClineCatalogService } from "./local-agent-cline-catalog.js"; import { ClineCatalogService as ClineCatalogServiceImpl } from "./local-agent-cline-catalog.js"; import { createMcpOpencodeCatalogSource } from "./local-agent-opencode-mcp-catalog.js"; -import { canonicalizePath, isPathInsideRoot } from "./roots.js"; +import { canonicalizePath, isPathInsideRoot, isSameWorktreePath } from "./roots.js"; import { assertNexusGrantAuthorizesExecution, assertSameExecutionGeneration, @@ -1007,6 +1007,7 @@ export class LocalAgentSessionManager { latest.externalRuntimeBinding?.runtimeKind === "HERDR" && !latest.externalRuntimeBinding.handle && ( + launchState === "FENCED" || (launchState === "WORKSPACE_OBSERVED" && message.startsWith("HerdR agent.start failed:")) || (launchState === "AGENT_OBSERVED" && message.startsWith("HerdR onboarding blocked:")) ); @@ -1135,7 +1136,7 @@ export class LocalAgentSessionManager { this.assertDispatchOwnershipAvailable(workspaceRoot, executionContract); let startCapacity = this.executionCapacitySnapshot(workspaceRoot); - if (startCapacity.localState === "EXHAUSTED" && this.usesHerdrBackend()) { + if ((startCapacity.localState === "EXHAUSTED" || (startCapacity.unreconciledStale ?? 0) > 0) && this.usesHerdrBackend()) { await this.reconcileStaleHerdRSessions(); startCapacity = this.executionCapacitySnapshot(workspaceRoot); } @@ -1333,7 +1334,7 @@ export class LocalAgentSessionManager { throw new AgentSessionError("UNKNOWN_AGENT", `Unknown agent id: ${agentId}`); } - if (canonicalizePath(record.workspaceRoot) !== canonicalizePath(workspaceRoot)) { + if (!isSameWorktreePath(record.workspaceRoot, workspaceRoot)) { throw new AgentSessionError( "AGENT_WORKSPACE_MISMATCH", `Agent ${agentId} belongs to workspace root '${record.workspaceRoot}', not '${workspaceRoot}'`, @@ -1614,7 +1615,7 @@ export class LocalAgentSessionManager { throw new AgentSessionError("UNKNOWN_AGENT", `Unknown agent id: ${agentId}`); } - if (canonicalizePath(record.workspaceRoot) !== canonicalizePath(workspaceRoot)) { + if (!isSameWorktreePath(record.workspaceRoot, workspaceRoot)) { throw new AgentSessionError( "AGENT_WORKSPACE_MISMATCH", `Agent ${agentId} belongs to workspace root '${record.workspaceRoot}', not '${workspaceRoot}'`, @@ -1659,7 +1660,7 @@ export class LocalAgentSessionManager { if (!record) { throw new AgentSessionError("UNKNOWN_AGENT", `Unknown agent id: ${agentId}`); } - if (canonicalizePath(record.workspaceRoot) !== canonicalizePath(workspaceRoot)) { + if (!isSameWorktreePath(record.workspaceRoot, workspaceRoot)) { throw new AgentSessionError( "AGENT_WORKSPACE_MISMATCH", `Agent ${agentId} belongs to workspace root '${record.workspaceRoot}', not '${workspaceRoot}'`, @@ -1803,10 +1804,9 @@ export class LocalAgentSessionManager { return records.slice(0, effectiveLimit).map(recordToSummary); } - const canonicalCurrent = canonicalizePath(workspaceRoot); const records = this.store.list(); const matched = records.filter( - (record) => canonicalizePath(record.workspaceRoot) === canonicalCurrent + (record) => isSameWorktreePath(record.workspaceRoot, workspaceRoot) ); return matched.slice(0, effectiveLimit).map(recordToSummary); } @@ -2021,7 +2021,7 @@ export class LocalAgentSessionManager { if (!record) { throw new AgentSessionError("UNKNOWN_AGENT", `Unknown agent id: ${agentId}`); } - if (canonicalizePath(record.workspaceRoot) !== canonicalizePath(workspaceRoot)) { + if (!isSameWorktreePath(record.workspaceRoot, workspaceRoot)) { throw new AgentSessionError( "AGENT_WORKSPACE_MISMATCH", `Agent ${agentId} belongs to workspace root '${record.workspaceRoot}', not '${workspaceRoot}'`, @@ -2242,9 +2242,8 @@ export class LocalAgentSessionManager { private executionCapacitySnapshot(workspaceRoot?: string): AgentPreflightOutput["capacity"] { const active = this.store.list().filter(occupiesDetachedExecutionSlot); - const canonicalRoot = workspaceRoot ? canonicalizePath(workspaceRoot) : undefined; - const activeInWorkspace = canonicalRoot - ? active.filter((record) => canonicalizePath(record.workspaceRoot) === canonicalRoot).length + const activeInWorkspace = workspaceRoot + ? active.filter((record) => isSameWorktreePath(record.workspaceRoot, workspaceRoot)).length : 0; let liveActive = 0; let unreconciledStale = 0; @@ -2278,10 +2277,9 @@ export class LocalAgentSessionManager { const writeScope = contract?.writePaths ?? []; if (!intent?.exclusiveOwnership || writeScope.length === 0) return; - const canonicalRoot = canonicalizePath(workspaceRoot); for (const record of this.store.list()) { if (!occupiesDetachedExecutionSlot(record)) continue; - if (canonicalizePath(record.workspaceRoot) !== canonicalRoot) continue; + if (!isSameWorktreePath(record.workspaceRoot, workspaceRoot)) continue; const activeWriteScope = record.executionContract?.writePaths; const activeIntent = record.executionContract?.dispatchIntent; @@ -2515,7 +2513,10 @@ export class LocalAgentSessionManager { const launch = record.externalRuntimeBinding?.launch; if ( !launch || - (launch.state !== "WORKSPACE_OBSERVED" && launch.state !== "AGENT_OBSERVED") || + (launch.state !== "WORKSPACE_OBSERVED" && + launch.state !== "AGENT_OBSERVED" && + launch.state !== "FENCED" && + launch.state !== "OUTCOME_UNKNOWN") || !launch.herdrSocketPath ) { return false; @@ -2528,7 +2529,16 @@ export class LocalAgentSessionManager { let externallyAbsent = false; try { - externallyAbsent = await this.herdrGateway.confirmObservedLaunchAbsent(launch); + if ( + (launch.state === "AGENT_OBSERVED" || launch.state === "WORKSPACE_OBSERVED") && + launch.herdrAgentIdentity && + launch.herdrWorkspaceId && + launch.herdrPaneId + ) { + externallyAbsent = await this.herdrGateway.confirmObservedLaunchAbsent(launch); + } else { + externallyAbsent = await this.herdrGateway.confirmPreAgentLaunchAbsent(launch); + } } catch { return false; } diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 41d4d4ba5..ccaa4454d 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -1268,6 +1268,7 @@ export class LocalAgentStore { current?.externalRuntimeBinding?.runtimeKind === "HERDR" && !current.externalRuntimeBinding.handle && ( + current.externalRuntimeBinding.launch?.state === "FENCED" || current.externalRuntimeBinding.launch?.state === "WORKSPACE_OBSERVED" || current.externalRuntimeBinding.launch?.state === "AGENT_OBSERVED" ), diff --git a/src/roots.test.ts b/src/roots.test.ts index 71fc32d1f..a614b6f69 100644 --- a/src/roots.test.ts +++ b/src/roots.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, realpathSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { assertAllowedPath, canonicalizePath, expandHomePath, resolveAllowedPath } from "./roots.js"; +import { assertAllowedPath, canonicalizePath, expandHomePath, resolveAllowedPath, isSameWorktreePath } from "./roots.js"; const home = homedir(); @@ -51,6 +51,12 @@ try { const expectedReconstructed = join(realpathSync(realTargetDir), "missing", "sub", "dir"); assert.equal(canonicalizePath(missingNestedPath), expectedReconstructed); + // 2b. isSameWorktreePath handles symlinks and platform-dependent casing + assert.equal(isSameWorktreePath(symlinkDir, realTargetDir), true); + if (process.platform === "darwin" || process.platform === "win32") { + assert.equal(isSameWorktreePath(realTargetDir.toUpperCase(), realTargetDir.toLowerCase()), true); + } + // 3. unexpected realpath error -> throws / fails closed if (process.platform !== "win32") { const loopLinkA = join(tempDir, "loopA"); diff --git a/src/roots.ts b/src/roots.ts index 0f1c4f45c..f9a713ee7 100644 --- a/src/roots.ts +++ b/src/roots.ts @@ -50,9 +50,10 @@ export function canonicalizePath(path: string): string { const missingSegments: string[] = []; let candidate = resolve(expandHomePath(path)); + const realpathFn = typeof realpathSync.native === "function" ? realpathSync.native : realpathSync; while (true) { try { - return resolve(realpathSync(candidate), ...missingSegments.slice().reverse()); + return resolve(realpathFn(candidate), ...missingSegments.slice().reverse()); } catch (error) { const err = error as NodeJS.ErrnoException; if (!err || (err.code !== "ENOENT" && err.code !== "ENOTDIR")) { @@ -66,3 +67,15 @@ export function canonicalizePath(path: string): string { } } } + +export function isSameWorktreePath(a: string | undefined, b: string | undefined): boolean { + if (!a || !b) return a === b; + if (a === b) return true; + const ca = canonicalizePath(a); + const cb = canonicalizePath(b); + if (ca === cb) return true; + if (process.platform === "darwin" || process.platform === "win32") { + return ca.toLowerCase() === cb.toLowerCase(); + } + return false; +}