Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/local-agent-herdr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HerdrSocketResponse<any>> => {
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);
Expand Down
54 changes: 45 additions & 9 deletions src/local-agent-herdr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`;
Expand All @@ -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(
Expand Down Expand Up @@ -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 &&
Expand All @@ -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<boolean> {
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.
Expand Down Expand Up @@ -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}'`,
);
Expand All @@ -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}'`,
);
Expand Down Expand Up @@ -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}'`,
);
Expand Down Expand Up @@ -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}'`,
Expand Down Expand Up @@ -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 &&
Expand Down
7 changes: 7 additions & 0 deletions src/local-agent-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2118,6 +2118,13 @@ test("Issue #256: HerdR stale lifecycle and capacity reconciliation 5D matrix",
return this.workspaceAbsent || this.agentAbsent;
}

override async confirmPreAgentLaunchAbsent(): Promise<boolean> {
if (!this.serverReachable || this.identityMismatch) {
throw new Error("Exact HerdR absence is unverified.");
}
return this.workspaceAbsent || this.agentAbsent;
}

override async startExternalAgent(params: any): Promise<HerdrExternalHandle> {
const handle: HerdrExternalHandle = {
schemaVersion: 1,
Expand Down
40 changes: 25 additions & 15 deletions src/local-agent-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:"))
);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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}'`,
Expand Down Expand Up @@ -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}'`,
Expand Down Expand Up @@ -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}'`,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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}'`,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions src/local-agent-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
),
Expand Down
8 changes: 7 additions & 1 deletion src/roots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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");
Expand Down
15 changes: 14 additions & 1 deletion src/roots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand All @@ -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;
}
Loading