mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-29 17:19:00 +08:00
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:
@@ -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
|
||||
|
||||
@@ -445,7 +445,6 @@ async function agentCommandInternal(
|
||||
sessionAgentId,
|
||||
lifecycleGeneration,
|
||||
runId,
|
||||
workspaceDir,
|
||||
executionWorkspaceDir:
|
||||
sessionEntry?.worktree?.canonicalWorkspaceDir ?? cwd ?? workspaceDir,
|
||||
watchSkills,
|
||||
|
||||
@@ -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---`;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user