From 4159a11ea3e0aceb84d515549e2faab9db26c356 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Sat, 16 May 2026 00:55:18 -0500 Subject: [PATCH] Fix stale bootstrap file breaking channel agent turns (#82463) * fix agents stale bootstrap context * docs changelog stale bootstrap fix --- CHANGELOG.md | 1 + src/agents/bootstrap-files.test.ts | 133 +++++++++++++++++++++++++++++ src/agents/bootstrap-files.ts | 58 ++++++++++++- src/agents/workspace.test.ts | 31 ++++++- src/agents/workspace.ts | 9 +- 5 files changed, 225 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f77f872302..66cd05b535a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index 47afcedd2b0..1573a87f9d8 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -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", () => { diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index 44ff8ccd079..813c994dbed 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -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(); 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 { + 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 { 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, ); diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 3477633cae2..a63818fb1ea 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -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 }); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index af528da8151..dc7f0f1c709 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -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 {