fix(workspace): store setup state outside workspace dot-dir (#93520)

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Lai Quang Huy <64073540+1qh@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-06-16 14:30:01 +08:00
committed by GitHub
parent e1d3f12d7f
commit 52280351bb
4 changed files with 161 additions and 83 deletions
+1
View File
@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, preserve fresh post-compaction usage while clearing stale usage snapshots, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #50795, #50845, #82874, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, @Hollychou924, @leno23, and @TurboTheTurtle.
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, avoid eager tool streaming for Claude 4.5 in Copilot, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #75393, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @Kailigithub, @rohitjavvadi, @samson910022, @liuhao1024, @bymle, and @mushuiyu886.
- Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
- Workspace setup state: store setup completion outside the workspace dot directory using an OpenClaw-named root file, migrate valid legacy state forward, and avoid clobbering generic root `workspace-state.json` files for TigerFS-style dot-path compatibility. This Clownfish replacement carries forward the focused #53326 fix idea because the original branch was closed and uneditable. (#53326, #44783, #39446) Thanks @1qh.
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.
- Control UI: preserve Gateway Access tokens during same-normalized WebSocket URL edits and reload gateway-scoped tokens when switching endpoints. Fixes #41545; repairs #42001 with additional source PRs #41546, #41552, and #41718. Thanks @wsyjh8, @llagy0020, @llagy007, @pingfanfan, and @zheliu2.
- Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652)
+44 -42
View File
@@ -108,6 +108,31 @@ async function createHeartbeatAgentsWorkspace() {
return workspaceDir;
}
async function writeCompletedWorkspaceState(workspaceDir: string): Promise<void> {
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",
);
}
async function writeLegacyCompletedWorkspaceState(workspaceDir: string): Promise<void> {
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",
);
}
function expectHeartbeatExcludedAndAgentsKept(files: WorkspaceBootstrapFile[]) {
// Heartbeat policy can remove HEARTBEAT.md for normal turns, but project rules
// must remain in the bootstrap set.
@@ -174,16 +199,7 @@ describe("resolveBootstrapFilesForRun", () => {
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 writeCompletedWorkspaceState(workspaceDir);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
@@ -193,9 +209,21 @@ describe("resolveBootstrapFilesForRun", () => {
expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
});
it("keeps BOOTSTRAP.md when setup state cannot be read", async () => {
it("ignores stale workspace BOOTSTRAP.md when legacy setup state is completed", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
await fs.mkdir(path.join(workspaceDir, ".openclaw", "workspace-state.json"), {
await writeLegacyCompletedWorkspaceState(workspaceDir);
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 current 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");
@@ -209,16 +237,7 @@ describe("resolveBootstrapFilesForRun", () => {
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 writeCompletedWorkspaceState(workspaceDir);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
@@ -231,16 +250,8 @@ describe("resolveBootstrapFilesForRun", () => {
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.mkdir(workspaceDir, { recursive: true });
await writeCompletedWorkspaceState(workspaceDir);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
@@ -263,17 +274,8 @@ describe("resolveBootstrapFilesForRun", () => {
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 writeCompletedWorkspaceState(workspaceDir);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
await fs.writeFile(
+46 -10
View File
@@ -66,7 +66,8 @@ describe("resolveDefaultAgentWorkspaceDir", () => {
});
});
const WORKSPACE_STATE_PATH_SEGMENTS = [".openclaw", "workspace-state.json"] as const;
const WORKSPACE_STATE_PATH_SEGMENTS = ["openclaw-workspace-state.json"] as const;
const LEGACY_WORKSPACE_STATE_PATH_SEGMENTS = [".openclaw", "workspace-state.json"] as const;
async function readWorkspaceState(dir: string): Promise<{
version: number;
@@ -81,6 +82,14 @@ async function readWorkspaceState(dir: string): Promise<{
};
}
async function writeLegacyWorkspaceState(dir: string, state: unknown): Promise<void> {
await fs.mkdir(path.join(dir, LEGACY_WORKSPACE_STATE_PATH_SEGMENTS[0]), { recursive: true });
await fs.writeFile(
path.join(dir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS),
`${JSON.stringify(state)}\n`,
);
}
async function expectBootstrapSeeded(dir: string) {
await expect(fs.access(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME))).resolves.toBeUndefined();
const state = await readWorkspaceState(dir);
@@ -128,9 +137,37 @@ describe("ensureAgentWorkspace", () => {
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectBootstrapSeeded(tempDir);
await expectPathMissing(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS));
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
});
it("does not overwrite a foreign root workspace-state.json file", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
const foreignStatePath = path.join(tempDir, "workspace-state.json");
const foreignState = "not openclaw state\n";
await fs.writeFile(foreignStatePath, foreignState);
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
expect(await fs.readFile(foreignStatePath, "utf-8")).toBe(foreignState);
await expectBootstrapSeeded(tempDir);
});
it("ignores unreadable legacy nested state while writing current setup state", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await fs.mkdir(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS), {
recursive: true,
});
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectBootstrapSeeded(tempDir);
const legacyStateStat = await fs.stat(
path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS),
);
expect(legacyStateStat.isDirectory()).toBe(true);
});
it("refuses to re-seed a recently attested workspace after the directory disappears", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
@@ -217,7 +254,7 @@ describe("ensureAgentWorkspace", () => {
const state = await fs.readFile(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS), "utf-8");
await fs.rm(tempDir, { recursive: true, force: true });
await fs.mkdir(path.join(tempDir, WORKSPACE_STATE_PATH_SEGMENTS[0]), { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
await fs.writeFile(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS), state);
@@ -527,19 +564,18 @@ describe("ensureAgentWorkspace", () => {
it("migrates legacy onboardingCompletedAt markers to setupCompletedAt", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await fs.mkdir(path.join(tempDir, ".openclaw"), { recursive: true });
await fs.writeFile(
path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS),
JSON.stringify({
version: 1,
onboardingCompletedAt: "2026-03-15T02:30:00.000Z",
}),
);
await writeLegacyWorkspaceState(tempDir, {
version: 1,
onboardingCompletedAt: "2026-03-15T02:30:00.000Z",
});
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
const state = await readWorkspaceState(tempDir);
expect(state.setupCompletedAt).toBe("2026-03-15T02:30:00.000Z");
await expect(
fs.access(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS)),
).resolves.toBeUndefined();
const persisted = await fs.readFile(
path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS),
"utf-8",
+70 -31
View File
@@ -36,8 +36,9 @@ export const DEFAULT_USER_FILENAME = "USER.md";
export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md";
export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
export const DEFAULT_MEMORY_FILENAME = CANONICAL_ROOT_MEMORY_FILENAME;
const WORKSPACE_STATE_DIRNAME = ".openclaw";
const WORKSPACE_STATE_FILENAME = "workspace-state.json";
const LEGACY_WORKSPACE_STATE_DIRNAME = ".openclaw";
const LEGACY_WORKSPACE_STATE_FILENAME = "workspace-state.json";
const WORKSPACE_STATE_FILENAME = "openclaw-workspace-state.json";
const WORKSPACE_STATE_VERSION = 1;
const WORKSPACE_ATTESTATION_SUFFIX = ".attested";
const WORKSPACE_ATTESTATION_DIRNAME = "workspace-attestations";
@@ -305,7 +306,11 @@ async function hasSkipBootstrapWorkspaceContentEvidence(dir: string): Promise<bo
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === ".DS_Store" || entry.name === WORKSPACE_STATE_DIRNAME) {
if (
entry.name === ".DS_Store" ||
entry.name === LEGACY_WORKSPACE_STATE_DIRNAME ||
entry.name === WORKSPACE_STATE_FILENAME
) {
continue;
}
if (entry.name === "skills" && entry.isDirectory()) {
@@ -455,7 +460,11 @@ async function reconcileWorkspaceBootstrapCompletionState(params: {
}
function resolveWorkspaceStatePath(dir: string): string {
return path.join(dir, WORKSPACE_STATE_DIRNAME, WORKSPACE_STATE_FILENAME);
return path.join(dir, WORKSPACE_STATE_FILENAME);
}
function resolveLegacyWorkspaceStatePath(dir: string): string {
return path.join(dir, LEGACY_WORKSPACE_STATE_DIRNAME, LEGACY_WORKSPACE_STATE_FILENAME);
}
export function resolveWorkspaceAttestationPath(dir: string): string {
@@ -670,37 +679,68 @@ function parseWorkspaceSetupState(raw: string): WorkspaceSetupState | null {
}
}
async function readWorkspaceSetupState(
statePath: string,
opts?: { persistLegacyMigration?: boolean },
): Promise<WorkspaceSetupState> {
function hasWorkspaceSetupStateMarker(state: WorkspaceSetupState): boolean {
return Boolean(state.bootstrapSeededAt || state.setupCompletedAt);
}
function needsWorkspaceSetupStateRewrite(raw: string, state: WorkspaceSetupState): boolean {
return (
raw.includes('"onboardingCompletedAt"') &&
!raw.includes('"setupCompletedAt"') &&
Boolean(state.setupCompletedAt)
);
}
async function readWorkspaceSetupStateFile(statePath: string): Promise<{
raw: string;
state: WorkspaceSetupState;
} | null> {
try {
const raw = await fs.readFile(statePath, "utf-8");
const parsed = parseWorkspaceSetupState(raw);
if (
opts?.persistLegacyMigration &&
parsed &&
raw.includes('"onboardingCompletedAt"') &&
!raw.includes('"setupCompletedAt"') &&
parsed.setupCompletedAt
) {
await writeWorkspaceSetupState(statePath, parsed);
}
return parsed ?? { version: WORKSPACE_STATE_VERSION };
return parsed ? { raw, state: parsed } : null;
} catch (err) {
const anyErr = err as { code?: string };
if (anyErr.code !== "ENOENT") {
throw err;
}
return {
version: WORKSPACE_STATE_VERSION,
};
return null;
}
}
async function readWorkspaceSetupStateForDir(dir: string): Promise<WorkspaceSetupState> {
const statePath = resolveWorkspaceStatePath(resolveUserPath(dir));
return await readWorkspaceSetupState(statePath);
async function readWorkspaceSetupStateForDir(
dir: string,
opts?: { persistLegacyMigration?: boolean },
): Promise<WorkspaceSetupState> {
const resolvedDir = resolveUserPath(dir);
const statePath = resolveWorkspaceStatePath(resolvedDir);
const canonical = await readWorkspaceSetupStateFile(statePath);
if (canonical) {
if (
opts?.persistLegacyMigration &&
needsWorkspaceSetupStateRewrite(canonical.raw, canonical.state)
) {
await writeWorkspaceSetupState(statePath, canonical.state);
}
return canonical.state;
}
const legacyStatePath = resolveLegacyWorkspaceStatePath(resolvedDir);
let legacy: Awaited<ReturnType<typeof readWorkspaceSetupStateFile>>;
try {
legacy = await readWorkspaceSetupStateFile(legacyStatePath);
} catch {
// Legacy state lived under a dot directory that some workspaces reject.
// Treat inaccessible legacy metadata as absent so current setup can proceed.
legacy = null;
}
if (!legacy) {
return { version: WORKSPACE_STATE_VERSION };
}
if (opts?.persistLegacyMigration && hasWorkspaceSetupStateMarker(legacy.state)) {
await writeWorkspaceSetupState(statePath, legacy.state);
}
return legacy.state;
}
export async function isWorkspaceSetupCompleted(dir: string): Promise<boolean> {
@@ -712,8 +752,7 @@ export async function resolveWorkspaceBootstrapStatus(
dir: string,
): Promise<"pending" | "complete"> {
const resolvedDir = resolveUserPath(dir);
const statePath = resolveWorkspaceStatePath(resolvedDir);
const state = await readWorkspaceSetupState(statePath);
const state = await readWorkspaceSetupStateForDir(resolvedDir);
if (typeof state.setupCompletedAt === "string" && state.setupCompletedAt.trim().length > 0) {
return "complete";
}
@@ -735,7 +774,7 @@ export async function reconcileWorkspaceBootstrapCompletion(
const resolvedDir = resolveUserPath(dir);
const statePath = resolveWorkspaceStatePath(resolvedDir);
const bootstrapPath = path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME);
const state = await readWorkspaceSetupState(statePath, {
const state = await readWorkspaceSetupStateForDir(resolvedDir, {
persistLegacyMigration: true,
});
return await reconcileWorkspaceBootstrapCompletionState({
@@ -753,7 +792,7 @@ async function writeWorkspaceSetupState(
await replaceFileAtomic({
filePath: statePath,
content: `${JSON.stringify(state, null, 2)}\n`,
tempPrefix: ".workspace-state",
tempPrefix: WORKSPACE_STATE_FILENAME,
});
}
@@ -885,10 +924,10 @@ export async function ensureAgentWorkspace(params?: {
if (recentAttestationPath && !isBrandNewWorkspace) {
const bootstrapExists = await pathExists(bootstrapPath);
const state = await readWorkspaceSetupState(statePath, {
const state = await readWorkspaceSetupStateForDir(dir, {
persistLegacyMigration: true,
});
const hasSetupState = Boolean(state.bootstrapSeededAt || state.setupCompletedAt);
const hasSetupState = hasWorkspaceSetupStateMarker(state);
const hasCustomizedRequiredBootstrap = await workspaceRequiredBootstrapLooksCustomized(dir, {
attestationPath: recentAttestationPath,
});
@@ -933,7 +972,7 @@ export async function ensureAgentWorkspace(params?: {
await writeFileIfMissing(heartbeatPath, heartbeatTemplate);
}
let state = await readWorkspaceSetupState(statePath, {
let state = await readWorkspaceSetupStateForDir(dir, {
persistLegacyMigration: true,
});
let stateDirty = false;