perf(skills): preserve canonical sources in worktree sessions (#158110)

Keep configured agent skill roots primary when Gateway ingress supplies a managed worktree for execution. Preserve canonical execution skills and sandbox mounts, and cover the conflicting worktree copies in the existing command and CLI regressions.
This commit is contained in:
Peter Steinberger
2026-09-25 13:19:24 +00:00
committed by GitHub
parent a4f6aa281d
commit e2d2e9fdb8
9 changed files with 126 additions and 103 deletions
+5 -2
View File
@@ -55,8 +55,11 @@ snapshot refresh and sandbox synchronization. Sandboxed runs read the
materialized copies, not the original host paths.
Managed worktree sessions keep their recorded canonical workspace as the skill
source. A selected nested workspace stays nested: discovery does not walk up to
its parent repository. Installing OpenClaw from a repository does not make that
source. The configured agent workspace remains the primary skill source even when
the session executes in a worktree; only selecting that worktree as the agent's
workspace gives its skills primary precedence. A selected nested workspace stays
nested: discovery does not walk up to its parent repository. Installing OpenClaw
from a repository does not make that
repository's `.agents/skills/` a global bundled skill source.
Each discovery pass reports one summary per winning/losing discovery root and
-1
View File
@@ -445,7 +445,6 @@ async function agentCommandInternal(
sessionAgentId,
lifecycleGeneration,
runId,
workspaceDir,
executionWorkspaceDir:
sessionEntry?.worktree?.canonicalWorkspaceDir ?? cwd ?? workspaceDir,
watchSkills,
+21
View File
@@ -44,6 +44,27 @@ export type TestCliBackendParams = {
systemPromptWhen?: "first" | "always" | "never";
};
export function createCliRepositorySkillFixture(dir: string, taskDir: string, managed: boolean) {
const canonicalDir = path.join(dir, "canonical", "packages", "app");
const skillDir = path.join(managed ? canonicalDir : taskDir, ".agents", "skills", "task-proof");
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(
path.join(skillDir, "SKILL.md"),
"---\nname: task-proof\ndescription: Task-local proof\n---\n# Proof instructions\n",
);
if (managed) {
for (const source of [".agents/skills", "skills"]) {
const worktreeSkillDir = path.join(taskDir, source, "task-proof");
fs.mkdirSync(worktreeSkillDir, { recursive: true });
fs.writeFileSync(
path.join(worktreeSkillDir, "SKILL.md"),
"---\nname: task-proof\ndescription: Worktree copy\n---\n# Changed instructions\n",
);
}
}
return { canonicalDir, skillDir };
}
export function wrappedPluginSystemContext(text: string) {
return `---\n\nOpenClaw plugin-injected system context. This block is not workspace file content.\n\n${text}\n\n---`;
}
+5 -7
View File
@@ -102,6 +102,7 @@ import { testing as cliBackendsTesting } from "../cli-backends.test-support.js";
import {
buildDefaultTestCliBackend,
createCliRunnerPrepareFixture,
createCliRepositorySkillFixture,
createTestMcpLoopbackClientGrant,
createTestMcpLoopbackServer,
createTestMcpLoopbackServerConfig,
@@ -3152,15 +3153,11 @@ describe("prepareCliRunContext", () => {
it.each([false, true])("uses admitted CLI repository skills (managed=%s)", async (managed) => {
const { dir } = fixture.session;
const taskDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-task-"));
const canonicalDir = path.join(dir, "canonical", "packages", "app");
const skillDir = path.join(managed ? canonicalDir : taskDir, ".agents", "skills", "task-proof");
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(
path.join(skillDir, "SKILL.md"),
"---\nname: task-proof\ndescription: Task-local proof\n---\n# Proof instructions\n",
);
const { canonicalDir, skillDir } = createCliRepositorySkillFixture(dir, taskDir, managed);
try {
const context = await fixture.prepare({
config: { agents: { defaults: { workspace: dir } } },
workspaceDir: taskDir,
cwd: taskDir,
skillsSnapshot: undefined,
...(managed
@@ -3184,6 +3181,7 @@ describe("prepareCliRunContext", () => {
expect(context.systemPrompt).not.toContain(`Working directory: ${dir}`);
expect(context.systemPrompt).toContain("<name>task-proof</name>");
expect(context.systemPrompt).toContain(path.join(skillDir, "SKILL.md"));
expect(context.systemPrompt).not.toContain("Worktree copy");
} finally {
fs.rmSync(taskDir, { recursive: true, force: true });
}
+2 -85
View File
@@ -50,10 +50,6 @@ import {
} from "../../routing/session-key.js";
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
import { captureAsyncWorkTracker } from "../../shared/async-work-scope.js";
import { resolveSkillsPrompt } from "../../skills/loading/workspace-skill-prompt.js";
import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js";
import { resolveReusableWorkspaceSkillSnapshot } from "../../skills/runtime/session-snapshot.js";
import type { SkillUsagePath } from "../../skills/types.js";
import { resolveUserPath } from "../../utils.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { resolveAdmittedRunActiveAssertion } from "../admitted-run-context.js";
@@ -107,11 +103,7 @@ import {
mergeForcedEmbeddedAttemptToolsAllow,
} from "../embedded-agent-runner/run/attempt-tool-construction-plan.js";
import { buildCurrentInboundPrompt } from "../embedded-agent-runner/run/runtime-context-prompt.js";
import {
mapSandboxSkillEntriesForPrompt,
remapSkillReferencePaths,
resolveSandboxSkillRuntimeInputs,
} from "../embedded-agent-runner/sandbox-skills.js";
import { remapSkillReferencePaths } from "../embedded-agent-runner/sandbox-skills.js";
import { selectContextEngineForTranscriptHost } from "../harness/context-engine-logical-turn.js";
import { drainPendingContextEngineTurnsBeforeRun } from "../harness/context-engine-turn-attempt.js";
import { createAgentQuestionAnswerAuthority } from "../harness/host-private-capabilities.js";
@@ -125,7 +117,6 @@ import {
type PreparedRootedExecutionCapability,
} from "../rooted-run-params.js";
import { collectRuntimeChannelCapabilities } from "../runtime-capabilities.js";
import { ensureSandboxWorkspaceForSession } from "../sandbox.js";
import { resolveSandboxRuntimeStatus } from "../sandbox/runtime-status.js";
import { buildSystemPromptReport } from "../system-prompt-report.js";
import { appendModelIdentitySystemPrompt, buildModelIdentityPromptLine } from "../system-prompt.js";
@@ -179,6 +170,7 @@ import {
loadCliSessionPromptContext,
resolveAutoCliSessionReseedHistoryChars,
} from "./session-history.js";
import { resolveCliSkillsPrompt } from "./skills-prompt.js";
import { prepareCliReplyToolAuthority } from "./tool-authority.js";
import {
captureCliRunStartTime,
@@ -257,81 +249,6 @@ function prependCliSessionDriftUserContext(
};
}
async function resolveCliSkillsPrompt(params: {
assertCurrent: () => void;
agentId: string;
config: RunCliAgentParams["config"];
sessionKey: string;
skillsSnapshot: RunCliAgentParams["skillsSnapshot"];
workspaceDir: string;
executionWorkspaceDir: string;
}): Promise<{ prompt: string; usagePaths?: SkillUsagePath[] }> {
params.assertCurrent();
const skillsSnapshot =
params.skillsSnapshot ??
(
await resolveReusableWorkspaceSkillSnapshot({
assertCurrent: params.assertCurrent,
workspaceDir: params.workspaceDir,
executionWorkspaceDir: params.executionWorkspaceDir,
config: params.config ?? {},
agentId: params.agentId,
watch: false,
})
).snapshot;
params.assertCurrent();
const sandboxWorkspace = await ensureSandboxWorkspaceForSession({
skillsSnapshot,
config: params.config,
agentId: params.agentId,
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
});
params.assertCurrent();
const {
skillsEligibility,
skillUsagePaths,
skillsPromptWorkspaceDir,
skillsSnapshot: skillsSnapshotForRun,
skillsWorkspaceDir,
workspaceOnly,
} = resolveSandboxSkillRuntimeInputs({
sandbox: sandboxWorkspace ? { ...sandboxWorkspace, enabled: true } : undefined,
skillsAnchorWorkspace: sandboxWorkspace?.workspaceDir ?? params.workspaceDir,
skillsSnapshot,
});
const { shouldLoadSkillEntries, skillEntries, loadSkillEntries, preserveEntryOrder } =
await resolveEmbeddedRunSkillEntries({
assertCurrent: params.assertCurrent,
workspaceDir: skillsWorkspaceDir,
...(sandboxWorkspace ? {} : { executionWorkspaceDir: params.executionWorkspaceDir }),
config: params.config,
agentId: params.agentId,
eligibility: skillsEligibility,
skillsSnapshot: skillsSnapshotForRun,
workspaceOnly,
});
const promptSkillEntries = mapSandboxSkillEntriesForPrompt({
entries: shouldLoadSkillEntries ? skillEntries : undefined,
skillsWorkspaceDir,
skillsPromptWorkspaceDir,
});
return {
...(sandboxWorkspace ? { usagePaths: skillUsagePaths } : {}),
prompt: await resolveSkillsPrompt({
assertCurrent: params.assertCurrent,
skillsSnapshot: skillsSnapshotForRun,
entries: promptSkillEntries,
...(sandboxWorkspace ? {} : { loadEntries: loadSkillEntries }),
workspaceDir: skillsPromptWorkspaceDir,
config: params.config,
agentId: params.agentId,
eligibility: skillsEligibility,
preserveEntryOrder,
}),
};
}
/** Overrides preparation dependencies for CLI runner tests. */
function setCliRunnerPrepareTestDeps(overrides: Partial<typeof prepareDeps>): void {
Object.assign(prepareDeps, overrides);
+87
View File
@@ -0,0 +1,87 @@
import { resolveSkillsPrompt } from "../../skills/loading/workspace-skill-prompt.js";
import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js";
import { resolveReusableWorkspaceSkillSnapshot } from "../../skills/runtime/session-snapshot.js";
import type { SkillUsagePath } from "../../skills/types.js";
import { resolveAgentWorkspaceDir } from "../agent-scope-config.js";
import {
mapSandboxSkillEntriesForPrompt,
resolveSandboxSkillRuntimeInputs,
} from "../embedded-agent-runner/sandbox-skills.js";
import { ensureSandboxWorkspaceForSession } from "../sandbox.js";
import type { RunCliAgentParams } from "./types.js";
export async function resolveCliSkillsPrompt(params: {
assertCurrent: () => void;
agentId: string;
config: RunCliAgentParams["config"];
sessionKey: string;
skillsSnapshot: RunCliAgentParams["skillsSnapshot"];
workspaceDir: string;
executionWorkspaceDir: string;
}): Promise<{ prompt: string; usagePaths?: SkillUsagePath[] }> {
params.assertCurrent();
const agentWorkspaceDir = resolveAgentWorkspaceDir(params.config ?? {}, params.agentId);
const skillsSnapshot =
params.skillsSnapshot ??
(
await resolveReusableWorkspaceSkillSnapshot({
assertCurrent: params.assertCurrent,
workspaceDir: agentWorkspaceDir,
executionWorkspaceDir: params.executionWorkspaceDir,
config: params.config ?? {},
agentId: params.agentId,
watch: false,
})
).snapshot;
params.assertCurrent();
const sandboxWorkspace = await ensureSandboxWorkspaceForSession({
skillsSnapshot,
config: params.config,
agentId: params.agentId,
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
});
params.assertCurrent();
const {
skillsEligibility,
skillUsagePaths,
skillsPromptWorkspaceDir,
skillsSnapshot: skillsSnapshotForRun,
skillsWorkspaceDir,
workspaceOnly,
} = resolveSandboxSkillRuntimeInputs({
sandbox: sandboxWorkspace ? { ...sandboxWorkspace, enabled: true } : undefined,
skillsAnchorWorkspace: sandboxWorkspace?.workspaceDir ?? agentWorkspaceDir,
skillsSnapshot,
});
const { shouldLoadSkillEntries, skillEntries, loadSkillEntries, preserveEntryOrder } =
await resolveEmbeddedRunSkillEntries({
assertCurrent: params.assertCurrent,
workspaceDir: skillsWorkspaceDir,
...(sandboxWorkspace ? {} : { executionWorkspaceDir: params.executionWorkspaceDir }),
config: params.config,
agentId: params.agentId,
eligibility: skillsEligibility,
skillsSnapshot: skillsSnapshotForRun,
workspaceOnly,
});
const promptSkillEntries = mapSandboxSkillEntriesForPrompt({
entries: shouldLoadSkillEntries ? skillEntries : undefined,
skillsWorkspaceDir,
skillsPromptWorkspaceDir,
});
return {
...(sandboxWorkspace ? { usagePaths: skillUsagePaths } : {}),
prompt: await resolveSkillsPrompt({
assertCurrent: params.assertCurrent,
skillsSnapshot: skillsSnapshotForRun,
entries: promptSkillEntries,
...(sandboxWorkspace ? {} : { loadEntries: loadSkillEntries }),
workspaceDir: skillsPromptWorkspaceDir,
config: params.config,
agentId: params.agentId,
eligibility: skillsEligibility,
preserveEntryOrder,
}),
};
}
@@ -73,7 +73,6 @@ it.each([
sessionAgentId: "main",
lifecycleGeneration,
runId,
workspaceDir: "/workspace",
executionWorkspaceDir: "/workspace",
watchSkills: false,
isNewSession: false,
+2 -2
View File
@@ -18,6 +18,7 @@ import {
buildCurrentRunRestartRecoveryClaim,
prepareCommandHarnessCompletionRecovery,
} from "../agent-command-restart-recovery.js";
import { resolveAgentWorkspaceDir } from "../agent-scope-config.js";
import { persistAgentSession } from "./attempt-execution.shared.js";
import { resolveAgentRunContext } from "./run-context.js";
import { loadExecDefaultsRuntime, loadSkillsRuntime } from "./runtime-loaders.js";
@@ -98,7 +99,6 @@ export async function prepareEmbeddedSessionState(params: {
sessionAgentId: string;
lifecycleGeneration: string;
runId: string;
workspaceDir: string;
executionWorkspaceDir: string;
watchSkills: boolean;
isNewSession: boolean;
@@ -146,7 +146,7 @@ export async function prepareEmbeddedSessionState(params: {
agentId: params.sessionAgentId,
});
const skillSnapshotState = await resolveReusableWorkspaceSkillSnapshot({
workspaceDir: params.workspaceDir,
workspaceDir: resolveAgentWorkspaceDir(params.cfg, params.sessionAgentId),
executionWorkspaceDir: params.executionWorkspaceDir,
config: params.cfg,
agentId: params.sessionAgentId,
+4 -5
View File
@@ -845,16 +845,15 @@ describe("agentCommand", () => {
{
message: "inspect this repo",
sessionKey,
workspaceDir: worktree.path,
allowModelOverride: false,
},
runtime,
);
expect(resolveReusableWorkspaceSkillSnapshot).toHaveBeenCalledWith(
expect.objectContaining({
executionWorkspaceDir: canonicalWorkspace,
}),
);
const skillRoots = vi.mocked(resolveReusableWorkspaceSkillSnapshot).mock.calls.at(-1)?.[0];
expect(skillRoots?.workspaceDir).toBe(path.join(home, "openclaw"));
expect(skillRoots?.executionWorkspaceDir).toBe(canonicalWorkspace);
});
});