Fix stale bootstrap file breaking channel agent turns (#82463)

* fix agents stale bootstrap context

* docs changelog stale bootstrap fix
This commit is contained in:
Josh Avant
2026-05-16 00:55:18 -05:00
committed by GitHub
parent da8c11aaae
commit 4159a11ea3
5 changed files with 225 additions and 7 deletions

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -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,
);

View File

@@ -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 });

View File

@@ -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 {