fix(agents): repair stale bootstrap completion (#71230)

* fix(agents): repair stale bootstrap completion

* fix: reconcile stale workspace bootstrap explicitly

* fix: keep bootstrap reconciliation in workspace lifecycle
This commit is contained in:
Patrick Erichsen
2026-04-24 15:41:11 -07:00
committed by GitHub
parent 5d1568963b
commit 137f5c3a8b
3 changed files with 239 additions and 26 deletions

View File

@@ -128,6 +128,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/bootstrap: repair completed workspaces that still have a stale `BOOTSTRAP.md` by detecting customized identity/profile files, deleting the stale bootstrap file, and recording setup completion.
- Codex harness: route native `request_user_input` prompts back to the originating chat, preserve queued follow-up answers, and honor newer app-server command approval amendment decisions.
- Codex harness/context-engine: redact context-engine assembly failures before logging, so fallback warnings do not serialize raw error objects. (#70809) Thanks @jalehman.
- WhatsApp/onboarding: keep first-run setup entry loading off the Baileys runtime dependency path, so packaged QuickStart installs can show WhatsApp setup before runtime deps are staged. Fixes #70932.

View File

@@ -9,12 +9,14 @@ import {
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_MEMORY_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_USER_FILENAME,
ensureAgentWorkspace,
filterBootstrapFilesForSession,
isWorkspaceBootstrapPending,
loadWorkspaceBootstrapFiles,
reconcileWorkspaceBootstrapCompletion,
resolveWorkspaceBootstrapStatus,
resolveDefaultAgentWorkspaceDir,
type WorkspaceBootstrapFile,
@@ -184,6 +186,80 @@ describe("ensureAgentWorkspace", () => {
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(true);
});
it("keeps bootstrap status read-only when stale completion evidence exists", 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",
});
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("pending");
await expect(
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
).resolves.toBeUndefined();
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
});
it("repairs stale BOOTSTRAP.md when profile files show onboarding completed", 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",
});
await expect(reconcileWorkspaceBootstrapCompletion(tempDir)).resolves.toMatchObject({
repaired: true,
bootstrapExists: false,
});
await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({
code: "ENOENT",
});
const state = await readWorkspaceState(tempDir);
expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
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);
});
it("uses SOUL.md customization as stale bootstrap completion evidence", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await writeWorkspaceFile({
dir: tempDir,
name: DEFAULT_SOUL_FILENAME,
content: "# SOUL.md\n\nUse a concise, practical voice.\n",
});
await expect(reconcileWorkspaceBootstrapCompletion(tempDir)).resolves.toMatchObject({
repaired: true,
bootstrapExists: false,
});
await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("does not treat git alone as stale bootstrap completion evidence", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await fs.mkdir(path.join(tempDir, ".git"), { recursive: true });
await fs.writeFile(path.join(tempDir, ".git", "HEAD"), "ref: refs/heads/main\n");
await expect(reconcileWorkspaceBootstrapCompletion(tempDir)).resolves.toMatchObject({
repaired: false,
bootstrapExists: true,
});
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("pending");
await expect(
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
).resolves.toBeUndefined();
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
});
it("reports bootstrap complete once BOOTSTRAP.md is deleted and completion is recorded", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");

View File

@@ -38,6 +38,11 @@ export const DEFAULT_MEMORY_FILENAME = CANONICAL_ROOT_MEMORY_FILENAME;
const WORKSPACE_STATE_DIRNAME = ".openclaw";
const WORKSPACE_STATE_FILENAME = "workspace-state.json";
const WORKSPACE_STATE_VERSION = 1;
const WORKSPACE_ONBOARDING_PROFILE_FILENAMES = [
DEFAULT_SOUL_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_USER_FILENAME,
] as const;
const workspaceTemplateCache = new Map<string, Promise<string>>();
let gitAvailabilityPromise: Promise<boolean> | null = null;
@@ -205,6 +210,111 @@ async function fileExists(filePath: string): Promise<boolean> {
}
}
async function fileContentDiffersFromTemplate(
filePath: string,
template: string,
): Promise<boolean> {
try {
return (await fs.readFile(filePath, "utf-8")) !== template;
} catch (err) {
const anyErr = err as { code?: string };
if (anyErr.code !== "ENOENT") {
throw err;
}
return false;
}
}
async function hasWorkspaceUserContentEvidence(
dir: string,
opts?: { includeGit?: boolean },
): Promise<boolean> {
const indicators = [path.join(dir, "memory")];
if (opts?.includeGit) {
indicators.push(path.join(dir, ".git"));
}
for (const indicator of indicators) {
try {
await fs.access(indicator);
return true;
} catch {
// continue
}
}
return await exactWorkspaceEntryExists(dir, DEFAULT_MEMORY_FILENAME);
}
async function workspaceProfileLooksConfigured(params: {
dir: string;
includeGitEvidence?: boolean;
}): Promise<boolean> {
const profileFileDiffs = await Promise.all(
WORKSPACE_ONBOARDING_PROFILE_FILENAMES.map(async (fileName) =>
fileContentDiffersFromTemplate(path.join(params.dir, fileName), await loadTemplate(fileName)),
),
);
return (
profileFileDiffs.some(Boolean) ||
(await hasWorkspaceUserContentEvidence(params.dir, {
includeGit: params.includeGitEvidence,
}))
);
}
async function workspaceHasBootstrapCompletionEvidence(params: { dir: string }): Promise<boolean> {
return await workspaceProfileLooksConfigured(params);
}
type WorkspaceBootstrapCompletionReconcileResult = {
repaired: boolean;
bootstrapExists: boolean;
state: WorkspaceSetupState;
};
async function reconcileWorkspaceBootstrapCompletionState(params: {
dir: string;
bootstrapPath: string;
statePath: string;
state: WorkspaceSetupState;
bootstrapExists?: boolean;
}): Promise<WorkspaceBootstrapCompletionReconcileResult> {
const bootstrapExists = params.bootstrapExists ?? (await fileExists(params.bootstrapPath));
if (
typeof params.state.setupCompletedAt === "string" &&
params.state.setupCompletedAt.trim().length > 0
) {
return { repaired: false, bootstrapExists, state: params.state };
}
if (params.state.bootstrapSeededAt && !bootstrapExists) {
const completedState: WorkspaceSetupState = {
...params.state,
setupCompletedAt: new Date().toISOString(),
};
await writeWorkspaceSetupState(params.statePath, completedState);
return { repaired: true, bootstrapExists: false, state: completedState };
}
if (
!bootstrapExists ||
!(await workspaceHasBootstrapCompletionEvidence({
dir: params.dir,
}))
) {
return { repaired: false, bootstrapExists, state: params.state };
}
const now = new Date().toISOString();
const repairedState: WorkspaceSetupState = {
...params.state,
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 };
}
function resolveWorkspaceStatePath(dir: string): string {
return path.join(dir, WORKSPACE_STATE_DIRNAME, WORKSPACE_STATE_FILENAME);
}
@@ -230,11 +340,15 @@ function parseWorkspaceSetupState(raw: string): WorkspaceSetupState | null {
}
}
async function readWorkspaceSetupState(statePath: string): Promise<WorkspaceSetupState> {
async function readWorkspaceSetupState(
statePath: string,
opts?: { persistLegacyMigration?: boolean },
): Promise<WorkspaceSetupState> {
try {
const raw = await fs.readFile(statePath, "utf-8");
const parsed = parseWorkspaceSetupState(raw);
if (
opts?.persistLegacyMigration &&
parsed &&
raw.includes('"onboardingCompletedAt"') &&
!raw.includes('"setupCompletedAt"') &&
@@ -268,18 +382,40 @@ export async function resolveWorkspaceBootstrapStatus(
dir: string,
): Promise<"pending" | "complete"> {
const resolvedDir = resolveUserPath(dir);
const state = await readWorkspaceSetupStateForDir(resolvedDir);
const statePath = resolveWorkspaceStatePath(resolvedDir);
const state = await readWorkspaceSetupState(statePath);
if (typeof state.setupCompletedAt === "string" && state.setupCompletedAt.trim().length > 0) {
return "complete";
}
const bootstrapExists = await fileExists(path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME));
return bootstrapExists ? "pending" : "complete";
const bootstrapPath = path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME);
const bootstrapExists = await fileExists(bootstrapPath);
if (!bootstrapExists) {
return "complete";
}
return "pending";
}
export async function isWorkspaceBootstrapPending(dir: string): Promise<boolean> {
return (await resolveWorkspaceBootstrapStatus(dir)) === "pending";
}
export async function reconcileWorkspaceBootstrapCompletion(
dir: string,
): Promise<WorkspaceBootstrapCompletionReconcileResult> {
const resolvedDir = resolveUserPath(dir);
const statePath = resolveWorkspaceStatePath(resolvedDir);
const bootstrapPath = path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME);
const state = await readWorkspaceSetupState(statePath, {
persistLegacyMigration: true,
});
return await reconcileWorkspaceBootstrapCompletionState({
dir: resolvedDir,
bootstrapPath,
statePath,
state,
});
}
async function writeWorkspaceSetupState(
statePath: string,
state: WorkspaceSetupState,
@@ -401,7 +537,9 @@ export async function ensureAgentWorkspace(params?: {
await writeFileIfMissing(userPath, userTemplate);
await writeFileIfMissing(heartbeatPath, heartbeatTemplate);
let state = await readWorkspaceSetupState(statePath);
let state = await readWorkspaceSetupState(statePath, {
persistLegacyMigration: true,
});
let stateDirty = false;
const markState = (next: Partial<WorkspaceSetupState>) => {
state = { ...state, ...next };
@@ -414,33 +552,31 @@ export async function ensureAgentWorkspace(params?: {
markState({ bootstrapSeededAt: nowIso() });
}
if (!state.setupCompletedAt && state.bootstrapSeededAt && !bootstrapExists) {
markState({ setupCompletedAt: nowIso() });
if (!state.setupCompletedAt) {
const repair = await reconcileWorkspaceBootstrapCompletionState({
dir,
bootstrapPath,
statePath,
state,
bootstrapExists,
});
if (repair.repaired) {
state = repair.state;
stateDirty = false;
bootstrapExists = repair.bootstrapExists;
}
}
if (!state.bootstrapSeededAt && !state.setupCompletedAt && !bootstrapExists) {
// Legacy migration path: if USER/IDENTITY diverged from templates, or if user-content
// indicators exist, treat setup as complete and avoid recreating BOOTSTRAP for
// already-configured workspaces.
const [identityContent, userContent] = await Promise.all([
fs.readFile(identityPath, "utf-8"),
fs.readFile(userPath, "utf-8"),
]);
const hasUserContent = await (async () => {
const indicators = [path.join(dir, "memory"), path.join(dir, ".git")];
for (const indicator of indicators) {
try {
await fs.access(indicator);
return true;
} catch {
// continue
}
}
return await exactWorkspaceEntryExists(dir, DEFAULT_MEMORY_FILENAME);
})();
const legacySetupCompleted =
identityContent !== identityTemplate || userContent !== userTemplate || hasUserContent;
if (legacySetupCompleted) {
if (
await workspaceProfileLooksConfigured({
dir,
includeGitEvidence: true,
})
) {
markState({ setupCompletedAt: nowIso() });
} else {
const bootstrapTemplate = await loadTemplate(DEFAULT_BOOTSTRAP_FILENAME);