mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 17:54:47 +00:00
Fix stale bootstrap file breaking channel agent turns (#82463)
* fix agents stale bootstrap context * docs changelog stale bootstrap fix
This commit is contained in:
@@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Voice calls: persist rejected inbound-call replay keys so duplicate carrier webhook retries stay ignored after a Gateway restart.
|
||||
- Config/doctor: copy fallback-enabled channel `allowFrom` entries into explicit `groupAllowFrom` allowlists during `openclaw doctor --fix`, preserving current group access without adding runtime fallback-transition flags.
|
||||
- Config/doctor: replace source-only official Brave and Slack plugin installs from trusted catalog metadata during `openclaw doctor --fix`, unblocking externalized stock plugin recovery after upgrade. (#82425) Thanks @joshavant.
|
||||
- Agents/bootstrap: ignore stale completed root `BOOTSTRAP.md` context after workspace setup cleanup fails, preventing channel agent turns from treating it as a directory. (#82463) Thanks @joshavant.
|
||||
- Configure: show one OpenAI provider entry with ChatGPT/Codex sign-in and API key choices, and keep browsed Codex models in the saved `/model` picker allowlist.
|
||||
- Agents/model fallback: preserve auto fallback chains across deferred config reloads when session fallback provenance survives but `modelOverrideSource` is missing. Fixes #81982. Thanks @joshavant.
|
||||
- Hooks: raise bounded gateway lifecycle hook wait budgets to 5 seconds for shutdown and 10 seconds for pre-restart, giving short restart notification handlers time to finish before shutdown continues. (#82273) Thanks @bryanbaer.
|
||||
|
||||
@@ -81,6 +81,21 @@ function registerDuplicateBootstrapFileHook() {
|
||||
});
|
||||
}
|
||||
|
||||
function registerBootstrapFileHook(relativePath = "BOOTSTRAP.md") {
|
||||
registerInternalHook("agent:bootstrap", (event) => {
|
||||
const context = event.context as AgentBootstrapHookContext;
|
||||
context.bootstrapFiles = [
|
||||
...context.bootstrapFiles,
|
||||
{
|
||||
name: "BOOTSTRAP.md",
|
||||
path: path.join(context.workspaceDir, relativePath),
|
||||
content: "stale ritual",
|
||||
missing: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
async function createHeartbeatAgentsWorkspace() {
|
||||
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
|
||||
await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8");
|
||||
@@ -149,6 +164,124 @@ describe("resolveBootstrapFilesForRun", () => {
|
||||
expect(agentsContextFiles).toHaveLength(1);
|
||||
expect(agentsContextFiles[0]?.content).toBe("workspace rules");
|
||||
});
|
||||
|
||||
it("ignores stale workspace BOOTSTRAP.md once setup is completed", async () => {
|
||||
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
|
||||
await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, ".openclaw", "workspace-state.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
|
||||
setupCompletedAt: "2026-05-16T00:00:01.000Z",
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
|
||||
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
|
||||
|
||||
const files = await resolveBootstrapFilesForRun({ workspaceDir });
|
||||
|
||||
expect(files.map((file) => file.name)).toContain("AGENTS.md");
|
||||
expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
|
||||
});
|
||||
|
||||
it("keeps BOOTSTRAP.md when setup state cannot be read", async () => {
|
||||
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
|
||||
await fs.mkdir(path.join(workspaceDir, ".openclaw", "workspace-state.json"), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
|
||||
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "ritual", "utf8");
|
||||
|
||||
const files = await resolveBootstrapFilesForRun({ workspaceDir });
|
||||
|
||||
expect(files.map((file) => file.name)).toContain("BOOTSTRAP.md");
|
||||
});
|
||||
|
||||
it("does not let hooks re-add stale root BOOTSTRAP.md after setup is completed", async () => {
|
||||
registerBootstrapFileHook();
|
||||
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
|
||||
await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, ".openclaw", "workspace-state.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
|
||||
setupCompletedAt: "2026-05-16T00:00:01.000Z",
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
|
||||
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
|
||||
|
||||
const files = await resolveBootstrapFilesForRun({ workspaceDir });
|
||||
|
||||
expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
|
||||
});
|
||||
|
||||
it("ignores stale root BOOTSTRAP.md for home-relative workspace paths", async () => {
|
||||
registerBootstrapFileHook();
|
||||
const parentDir = await makeTempWorkspace("openclaw-bootstrap-home-");
|
||||
const workspaceDir = path.join(parentDir, "workspace");
|
||||
await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, ".openclaw", "workspace-state.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
|
||||
setupCompletedAt: "2026-05-16T00:00:01.000Z",
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
|
||||
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
|
||||
|
||||
const previousOpenClawHome = process.env.OPENCLAW_HOME;
|
||||
process.env.OPENCLAW_HOME = parentDir;
|
||||
try {
|
||||
const files = await resolveBootstrapFilesForRun({ workspaceDir: "~/workspace" });
|
||||
|
||||
expect(files.map((file) => file.name)).toContain("AGENTS.md");
|
||||
expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
|
||||
} finally {
|
||||
if (previousOpenClawHome === undefined) {
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
} else {
|
||||
process.env.OPENCLAW_HOME = previousOpenClawHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps hook-added nested BOOTSTRAP.md after setup is completed", async () => {
|
||||
registerBootstrapFileHook(path.join("packages", "core", "BOOTSTRAP.md"));
|
||||
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
|
||||
await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
|
||||
await fs.mkdir(path.join(workspaceDir, "packages", "core"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, ".openclaw", "workspace-state.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
|
||||
setupCompletedAt: "2026-05-16T00:00:01.000Z",
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
|
||||
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "packages", "core", "BOOTSTRAP.md"),
|
||||
"package ritual",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const files = await resolveBootstrapFilesForRun({ workspaceDir });
|
||||
|
||||
expect(files.map((file) => path.relative(workspaceDir, file.path))).toContain(
|
||||
path.join("packages", "core", "BOOTSTRAP.md"),
|
||||
);
|
||||
expect(files.map((file) => file.path)).not.toContain(path.join(workspaceDir, "BOOTSTRAP.md"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBootstrapContextForRun", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import type { AgentContextInjection } from "../config/types.agent-defaults.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveAgentConfig, resolveSessionAgentIds } from "./agent-scope.js";
|
||||
import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js";
|
||||
import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js";
|
||||
@@ -15,7 +16,9 @@ import {
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_FILENAME,
|
||||
DEFAULT_BOOTSTRAP_FILENAME,
|
||||
filterBootstrapFilesForSession,
|
||||
isWorkspaceSetupCompleted,
|
||||
isWorkspaceBootstrapPending,
|
||||
loadWorkspaceBootstrapFiles,
|
||||
type WorkspaceBootstrapFile,
|
||||
@@ -158,7 +161,7 @@ function sanitizeBootstrapFiles(
|
||||
workspaceDir: string,
|
||||
warn?: (message: string) => void,
|
||||
): WorkspaceBootstrapFile[] {
|
||||
const workspaceRoot = path.resolve(workspaceDir);
|
||||
const workspaceRoot = resolveUserPath(workspaceDir);
|
||||
const seenPaths = new Set<string>();
|
||||
const sanitized: WorkspaceBootstrapFile[] = [];
|
||||
for (const file of files) {
|
||||
@@ -171,7 +174,9 @@ function sanitizeBootstrapFiles(
|
||||
}
|
||||
const resolvedPath = path.isAbsolute(pathValue)
|
||||
? path.resolve(pathValue)
|
||||
: path.resolve(workspaceRoot, pathValue);
|
||||
: pathValue.startsWith("~")
|
||||
? resolveUserPath(pathValue)
|
||||
: path.resolve(workspaceRoot, pathValue);
|
||||
const dedupeKey = path.normalize(path.relative(workspaceRoot, resolvedPath));
|
||||
if (seenPaths.has(dedupeKey)) {
|
||||
continue;
|
||||
@@ -234,6 +239,41 @@ function filterHeartbeatBootstrapFile(
|
||||
return files.filter((file) => file.name !== DEFAULT_HEARTBEAT_FILENAME);
|
||||
}
|
||||
|
||||
function filterCompletedWorkspaceBootstrapFile(
|
||||
files: WorkspaceBootstrapFile[],
|
||||
setupCompleted: boolean,
|
||||
workspaceDir: string,
|
||||
): WorkspaceBootstrapFile[] {
|
||||
if (!setupCompleted) {
|
||||
return files;
|
||||
}
|
||||
const workspaceRoot = resolveUserPath(workspaceDir);
|
||||
const rootBootstrapPath = path.join(workspaceRoot, DEFAULT_BOOTSTRAP_FILENAME);
|
||||
return files.filter((file) => {
|
||||
if (file.name !== DEFAULT_BOOTSTRAP_FILENAME) {
|
||||
return true;
|
||||
}
|
||||
const pathValue = normalizeOptionalString(file.path);
|
||||
if (!pathValue) {
|
||||
return true;
|
||||
}
|
||||
const resolvedPath = path.isAbsolute(pathValue)
|
||||
? path.resolve(pathValue)
|
||||
: pathValue.startsWith("~")
|
||||
? resolveUserPath(pathValue)
|
||||
: path.resolve(workspaceRoot, pathValue);
|
||||
return resolvedPath !== rootBootstrapPath;
|
||||
});
|
||||
}
|
||||
|
||||
async function isWorkspaceSetupCompletedForContext(workspaceDir: string): Promise<boolean> {
|
||||
try {
|
||||
return await isWorkspaceSetupCompleted(workspaceDir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveBootstrapFilesForRun(params: {
|
||||
workspaceDir: string;
|
||||
config?: OpenClawConfig;
|
||||
@@ -246,6 +286,7 @@ export async function resolveBootstrapFilesForRun(params: {
|
||||
}): Promise<WorkspaceBootstrapFile[]> {
|
||||
const excludeHeartbeatBootstrapFile = shouldExcludeHeartbeatBootstrapFile(params);
|
||||
const sessionKey = params.sessionKey ?? params.sessionId;
|
||||
const workspaceSetupCompleted = await isWorkspaceSetupCompletedForContext(params.workspaceDir);
|
||||
const rawFiles = params.sessionKey
|
||||
? await getOrLoadBootstrapFiles({
|
||||
workspaceDir: params.workspaceDir,
|
||||
@@ -253,7 +294,11 @@ export async function resolveBootstrapFilesForRun(params: {
|
||||
})
|
||||
: await loadWorkspaceBootstrapFiles(params.workspaceDir);
|
||||
const bootstrapFiles = applyContextModeFilter({
|
||||
files: filterBootstrapFilesForSession(rawFiles, sessionKey),
|
||||
files: filterCompletedWorkspaceBootstrapFile(
|
||||
filterBootstrapFilesForSession(rawFiles, sessionKey),
|
||||
workspaceSetupCompleted,
|
||||
params.workspaceDir,
|
||||
),
|
||||
contextMode: params.contextMode,
|
||||
runKind: params.runKind,
|
||||
});
|
||||
@@ -266,8 +311,13 @@ export async function resolveBootstrapFilesForRun(params: {
|
||||
sessionId: params.sessionId,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const filteredUpdated = filterCompletedWorkspaceBootstrapFile(
|
||||
updated,
|
||||
workspaceSetupCompleted,
|
||||
params.workspaceDir,
|
||||
);
|
||||
return sanitizeBootstrapFiles(
|
||||
filterHeartbeatBootstrapFile(updated, excludeHeartbeatBootstrapFile),
|
||||
filterHeartbeatBootstrapFile(filteredUpdated, excludeHeartbeatBootstrapFile),
|
||||
params.workspaceDir,
|
||||
params.warn,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
|
||||
import {
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
@@ -266,6 +266,35 @@ describe("ensureAgentWorkspace", () => {
|
||||
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("records stale bootstrap completion when BOOTSTRAP.md cleanup fails", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
await writeWorkspaceFile({
|
||||
dir: tempDir,
|
||||
name: DEFAULT_IDENTITY_FILENAME,
|
||||
content: "# IDENTITY.md\n\n- **Name:** Example\n",
|
||||
});
|
||||
const bootstrapPath = path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME);
|
||||
const rmSpy = vi
|
||||
.spyOn(fs, "rm")
|
||||
.mockRejectedValueOnce(Object.assign(new Error("not a directory"), { code: "ENOTDIR" }));
|
||||
|
||||
try {
|
||||
const result = await reconcileWorkspaceBootstrapCompletion(tempDir);
|
||||
|
||||
expect(result.repaired).toBe(true);
|
||||
expect(result.bootstrapExists).toBe(true);
|
||||
expect(rmSpy).toHaveBeenCalledWith(bootstrapPath, { force: true });
|
||||
await expect(fs.access(bootstrapPath)).resolves.toBeUndefined();
|
||||
const state = await readWorkspaceState(tempDir);
|
||||
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("complete");
|
||||
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false);
|
||||
} finally {
|
||||
rmSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses SOUL.md customization as stale bootstrap completion evidence", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
@@ -299,9 +299,14 @@ async function reconcileWorkspaceBootstrapCompletionState(params: {
|
||||
bootstrapSeededAt: params.state.bootstrapSeededAt ?? now,
|
||||
setupCompletedAt: now,
|
||||
};
|
||||
await fs.rm(params.bootstrapPath, { force: true });
|
||||
await writeWorkspaceSetupState(params.statePath, repairedState);
|
||||
return { repaired: true, bootstrapExists: false, state: repairedState };
|
||||
try {
|
||||
await fs.rm(params.bootstrapPath, { force: true });
|
||||
return { repaired: true, bootstrapExists: false, state: repairedState };
|
||||
} catch {
|
||||
// Completion state is authoritative; stale BOOTSTRAP cleanup is best-effort.
|
||||
return { repaired: true, bootstrapExists: true, state: repairedState };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorkspaceStatePath(dir: string): string {
|
||||
|
||||
Reference in New Issue
Block a user