fix(workspace): persist bootstrap onboarding state

This commit is contained in:
Gustavo Madeira Santana
2026-02-14 18:56:26 -05:00
parent ea0ef18704
commit 28b78b25b7
3 changed files with 178 additions and 14 deletions

View File

@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
- Agents: add a safety timeout around embedded `session.compact()` to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.
- Gateway/Sessions: abort active embedded runs and clear queued session work before `sessions.reset`, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.
- Agents/Process/Bootstrap: preserve unbounded `process log` offset-only pagination (default tail applies only when both `offset` and `limit` are omitted) and enforce strict `bootstrapTotalMaxChars` budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman.
- Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing `BOOTSTRAP.md` once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated.
- Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.
- Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including `session_status` model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader.
- Agents/Workspace: create `BOOTSTRAP.md` when core workspace files are seeded in partially initialized workspaces, while keeping BOOTSTRAP one-shot after onboarding deletion. (#16457) Thanks @robbyczgw-cla.

View File

@@ -5,8 +5,11 @@ import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace
import {
DEFAULT_AGENTS_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_MEMORY_ALT_FILENAME,
DEFAULT_MEMORY_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_USER_FILENAME,
ensureAgentWorkspace,
loadWorkspaceBootstrapFiles,
resolveDefaultAgentWorkspaceDir,
@@ -23,8 +26,23 @@ describe("resolveDefaultAgentWorkspaceDir", () => {
});
});
const WORKSPACE_STATE_PATH_SEGMENTS = [".openclaw", "workspace-state.json"] as const;
async function readOnboardingState(dir: string): Promise<{
version: number;
bootstrapSeededAt?: string;
onboardingCompletedAt?: string;
}> {
const raw = await fs.readFile(path.join(dir, ...WORKSPACE_STATE_PATH_SEGMENTS), "utf-8");
return JSON.parse(raw) as {
version: number;
bootstrapSeededAt?: string;
onboardingCompletedAt?: string;
};
}
describe("ensureAgentWorkspace", () => {
it("creates BOOTSTRAP.md for a brand new workspace", async () => {
it("creates BOOTSTRAP.md and records a seeded marker for brand new workspaces", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
@@ -32,9 +50,12 @@ describe("ensureAgentWorkspace", () => {
await expect(
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
).resolves.toBeUndefined();
const state = await readOnboardingState(tempDir);
expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
expect(state.onboardingCompletedAt).toBeUndefined();
});
it("creates BOOTSTRAP.md even when workspace already has other bootstrap files", async () => {
it("recovers partial initialization by creating BOOTSTRAP.md when marker is missing", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_AGENTS_FILENAME, content: "existing" });
@@ -43,18 +64,41 @@ describe("ensureAgentWorkspace", () => {
await expect(
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
).resolves.toBeUndefined();
const state = await readOnboardingState(tempDir);
expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
it("does not recreate BOOTSTRAP.md after onboarding deletion", async () => {
it("does not recreate BOOTSTRAP.md after completion, even when a core file is recreated", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_IDENTITY_FILENAME, content: "custom" });
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_USER_FILENAME, content: "custom" });
await fs.unlink(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
await fs.unlink(path.join(tempDir, DEFAULT_TOOLS_FILENAME));
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({
code: "ENOENT",
});
await expect(fs.access(path.join(tempDir, DEFAULT_TOOLS_FILENAME))).resolves.toBeUndefined();
const state = await readOnboardingState(tempDir);
expect(state.onboardingCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
it("does not re-seed BOOTSTRAP.md for legacy completed workspaces without state marker", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_IDENTITY_FILENAME, content: "custom" });
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_USER_FILENAME, content: "custom" });
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({
code: "ENOENT",
});
const state = await readOnboardingState(tempDir);
expect(state.bootstrapSeededAt).toBeUndefined();
expect(state.onboardingCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
});

View File

@@ -29,6 +29,9 @@ export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md";
export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
export const DEFAULT_MEMORY_FILENAME = "MEMORY.md";
export const DEFAULT_MEMORY_ALT_FILENAME = "memory.md";
const WORKSPACE_STATE_DIRNAME = ".openclaw";
const WORKSPACE_STATE_FILENAME = "workspace-state.json";
const WORKSPACE_STATE_VERSION = 1;
const workspaceTemplateCache = new Map<string, Promise<string>>();
let gitAvailabilityPromise: Promise<boolean> | null = null;
@@ -93,6 +96,12 @@ export type WorkspaceBootstrapFile = {
missing: boolean;
};
type WorkspaceOnboardingState = {
version: typeof WORKSPACE_STATE_VERSION;
bootstrapSeededAt?: string;
onboardingCompletedAt?: string;
};
/** Set of recognized bootstrap filenames for runtime validation */
const VALID_BOOTSTRAP_NAMES: ReadonlySet<string> = new Set([
DEFAULT_AGENTS_FILENAME,
@@ -122,6 +131,75 @@ async function writeFileIfMissing(filePath: string, content: string): Promise<bo
}
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
function resolveWorkspaceStatePath(dir: string): string {
return path.join(dir, WORKSPACE_STATE_DIRNAME, WORKSPACE_STATE_FILENAME);
}
function parseWorkspaceOnboardingState(raw: string): WorkspaceOnboardingState | null {
try {
const parsed = JSON.parse(raw) as {
bootstrapSeededAt?: unknown;
onboardingCompletedAt?: unknown;
};
if (!parsed || typeof parsed !== "object") {
return null;
}
return {
version: WORKSPACE_STATE_VERSION,
bootstrapSeededAt:
typeof parsed.bootstrapSeededAt === "string" ? parsed.bootstrapSeededAt : undefined,
onboardingCompletedAt:
typeof parsed.onboardingCompletedAt === "string" ? parsed.onboardingCompletedAt : undefined,
};
} catch {
return null;
}
}
async function readWorkspaceOnboardingState(statePath: string): Promise<WorkspaceOnboardingState> {
try {
const raw = await fs.readFile(statePath, "utf-8");
return (
parseWorkspaceOnboardingState(raw) ?? {
version: WORKSPACE_STATE_VERSION,
}
);
} catch (err) {
const anyErr = err as { code?: string };
if (anyErr.code !== "ENOENT") {
throw err;
}
return {
version: WORKSPACE_STATE_VERSION,
};
}
}
async function writeWorkspaceOnboardingState(
statePath: string,
state: WorkspaceOnboardingState,
): Promise<void> {
await fs.mkdir(path.dirname(statePath), { recursive: true });
const payload = `${JSON.stringify(state, null, 2)}\n`;
const tmpPath = `${statePath}.tmp-${process.pid}-${Date.now().toString(36)}`;
try {
await fs.writeFile(tmpPath, payload, { encoding: "utf-8" });
await fs.rename(tmpPath, statePath);
} catch (err) {
await fs.unlink(tmpPath).catch(() => {});
throw err;
}
}
async function hasGitRepo(dir: string): Promise<boolean> {
try {
await fs.stat(path.join(dir, ".git"));
@@ -193,6 +271,7 @@ export async function ensureAgentWorkspace(params?: {
const userPath = path.join(dir, DEFAULT_USER_FILENAME);
const heartbeatPath = path.join(dir, DEFAULT_HEARTBEAT_FILENAME);
const bootstrapPath = path.join(dir, DEFAULT_BOOTSTRAP_FILENAME);
const statePath = resolveWorkspaceStatePath(dir);
const isBrandNewWorkspace = await (async () => {
const paths = [agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath];
@@ -215,17 +294,57 @@ export async function ensureAgentWorkspace(params?: {
const identityTemplate = await loadTemplate(DEFAULT_IDENTITY_FILENAME);
const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME);
const heartbeatTemplate = await loadTemplate(DEFAULT_HEARTBEAT_FILENAME);
const wroteAgents = await writeFileIfMissing(agentsPath, agentsTemplate);
const wroteSoul = await writeFileIfMissing(soulPath, soulTemplate);
const wroteTools = await writeFileIfMissing(toolsPath, toolsTemplate);
const wroteIdentity = await writeFileIfMissing(identityPath, identityTemplate);
const wroteUser = await writeFileIfMissing(userPath, userTemplate);
const wroteHeartbeat = await writeFileIfMissing(heartbeatPath, heartbeatTemplate);
const wroteAnyCoreBootstrapFile =
wroteAgents || wroteSoul || wroteTools || wroteIdentity || wroteUser || wroteHeartbeat;
if (isBrandNewWorkspace || wroteAnyCoreBootstrapFile) {
const bootstrapTemplate = await loadTemplate(DEFAULT_BOOTSTRAP_FILENAME);
await writeFileIfMissing(bootstrapPath, bootstrapTemplate);
await writeFileIfMissing(agentsPath, agentsTemplate);
await writeFileIfMissing(soulPath, soulTemplate);
await writeFileIfMissing(toolsPath, toolsTemplate);
await writeFileIfMissing(identityPath, identityTemplate);
await writeFileIfMissing(userPath, userTemplate);
await writeFileIfMissing(heartbeatPath, heartbeatTemplate);
let state = await readWorkspaceOnboardingState(statePath);
let stateDirty = false;
const markState = (next: Partial<WorkspaceOnboardingState>) => {
state = { ...state, ...next };
stateDirty = true;
};
const nowIso = () => new Date().toISOString();
let bootstrapExists = await fileExists(bootstrapPath);
if (!state.bootstrapSeededAt && bootstrapExists) {
markState({ bootstrapSeededAt: nowIso() });
}
if (!state.onboardingCompletedAt && state.bootstrapSeededAt && !bootstrapExists) {
markState({ onboardingCompletedAt: nowIso() });
}
if (!state.bootstrapSeededAt && !state.onboardingCompletedAt && !bootstrapExists) {
// Legacy migration path: if USER/IDENTITY diverged from templates, treat onboarding as complete
// and avoid recreating BOOTSTRAP for already-onboarded workspaces.
const [identityContent, userContent] = await Promise.all([
fs.readFile(identityPath, "utf-8"),
fs.readFile(userPath, "utf-8"),
]);
const legacyOnboardingCompleted =
identityContent !== identityTemplate || userContent !== userTemplate;
if (legacyOnboardingCompleted) {
markState({ onboardingCompletedAt: nowIso() });
} else {
const bootstrapTemplate = await loadTemplate(DEFAULT_BOOTSTRAP_FILENAME);
const wroteBootstrap = await writeFileIfMissing(bootstrapPath, bootstrapTemplate);
if (!wroteBootstrap) {
bootstrapExists = await fileExists(bootstrapPath);
} else {
bootstrapExists = true;
}
if (bootstrapExists && !state.bootstrapSeededAt) {
markState({ bootstrapSeededAt: nowIso() });
}
}
}
if (stateDirty) {
await writeWorkspaceOnboardingState(statePath, state);
}
await ensureGitRepo(dir, isBrandNewWorkspace);