Skip to content

Commit 541dcb4

Browse files
committed
Auto-merge upstream openclaw/openclaw
2 parents 0c38f07 + 4eb7160 commit 541dcb4

28 files changed

Lines changed: 4688 additions & 62 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
3030
- Sessions/model selection: preserve catalog-backed session model labels and keep already-qualified session model refs stable when catalog metadata is unavailable, so Control UI model selection survives reloads without bogus provider-prefixed values. (#61382) Thanks @Mule-ME.
3131
- Gateway/startup: keep WebSocket RPC available while channels and plugin sidecars start, hold `chat.history` unavailable until startup sidecars finish so synchronous history reads cannot stall startup (reported in #63450), refresh advertised gateway methods after deferred plugin reloads, and enforce the pre-auth WebSocket upgrade budget before the no-handler 503 path so upgrade floods cannot bypass connection limits during that window. (#63480) Thanks @neeravmakwana.
3232
- Dreaming/cron: reconcile managed dreaming cron from the resolved gateway startup config so boot-time schedule recovery respects the configured cadence and timezone. (#63873) Thanks @mbelinky.
33+
- Dreaming/cron: keep managed dreaming cron reconciled after startup by rechecking lifecycle state during runtime config/plugin changes, recovering missing managed jobs, and applying cadence/timezone updates idempotently. (#63929) Thanks @mbelinky.
3334
- Gateway/tailscale: start Tailscale exposure and the gateway update check before awaiting channel and plugin sidecar startup so remote operators are not locked out when startup sidecars stall.
3435
- QQBot/streaming: make block streaming configurable per QQ bot account via `streaming.mode` (`"partial"` | `"off"`, default `"partial"`) instead of hardcoding it off, so responses can be delivered incrementally. (#63746)
3536
- Dreaming/gateway: require `operator.admin` for persistent `/dreaming on|off` changes and treat missing gateway client scopes as unprivileged instead of silently allowing config writes. (#63872) Thanks @mbelinky.
@@ -50,7 +51,12 @@ Docs: https://docs.openclaw.ai
5051
- Matrix/streaming: preserve ordered block flushes before tool, message, and agent boundaries, add explicit `channels.matrix.blockStreaming` opt-in so Matrix `streaming: "off"` stays final-only by default, and move MiniMax plain-text final handling into the MiniMax provider runtime instead of the shared core heuristic. (#59266) thanks @gumadeiras
5152
- Gateway/agents: fix stale run-context TTL cleanup so the new maintenance sweep compiles and resets orphaned run sequence state correctly. (#52731) thanks @artwalker
5253
- Memory/lancedb: accept `dreaming` config when `memory-lancedb` owns the memory slot so Dreaming surfaces can read slot-owner settings without schema rejection. (#63874) Thanks @mbelinky.
54+
- Control UI/dreaming: keep the Dreaming trace area contained and scrollable so overlays no longer cover tabs or blow out the page layout. (#63875) Thanks @mbelinky.
55+
- Dreaming/diary: add idempotent narrative subagent runs, preserve restrictive `DREAMS.md` permissions during atomic writes, and surface temp cleanup failures so repeated sweeps do not double-run the same narrative request or silently weaken diary safety. (#63876) Thanks @mbelinky.
5356
- Heartbeats/sessions: remove stale accumulated isolated heartbeat session keys when the next tick converges them back to the canonical sibling, so repaired sessions stop showing orphaned `:heartbeat:heartbeat` variants in session listings. (#59606) Thanks @rogerdigital.
57+
- Cron/Telegram: collapse isolated announce delivery to the final assistant-visible text only for Telegram targets, while preserving existing multi-message direct delivery semantics for other channels. (#63228) Thanks @welfo-beo.
58+
- Gateway/thread routing: preserve Slack, Telegram, and Mattermost thread-child delivery targets so bound subagent completion messages land in the originating thread instead of top-level channels. (#54840) Thanks @yzzymt.
59+
- ACP/stream relay: pass parent delivery context to ACP stream relay system events so `streamTo="parent"` updates route to the correct thread or topic instead of falling back to the main DM. (#57056) Thanks @pingren.
5460

5561
## 2026.4.9
5662

extensions/memory-core/src/dreaming-narrative.test.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
3-
import { describe, expect, it, vi } from "vitest";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
44
import {
55
appendNarrativeEntry,
66
buildBackfillDiaryEntry,
@@ -18,6 +18,10 @@ import { createMemoryCoreTestHarness } from "./test-helpers.js";
1818

1919
const { createTempWorkspace } = createMemoryCoreTestHarness();
2020

21+
afterEach(() => {
22+
vi.restoreAllMocks();
23+
});
24+
2125
describe("buildNarrativePrompt", () => {
2226
it("builds a prompt from snippets only", () => {
2327
const data: NarrativePhaseData = {
@@ -312,6 +316,64 @@ describe("appendNarrativeEntry", () => {
312316
// Original content should still be there, after the diary.
313317
expect(content).toContain("# Existing");
314318
});
319+
320+
it("keeps existing diary content intact when the atomic replace fails", async () => {
321+
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
322+
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
323+
await fs.writeFile(dreamsPath, "# Existing\n", "utf-8");
324+
const renameError = Object.assign(new Error("replace failed"), { code: "ENOSPC" });
325+
const renameSpy = vi.spyOn(fs, "rename").mockRejectedValueOnce(renameError);
326+
327+
await expect(
328+
appendNarrativeEntry({
329+
workspaceDir,
330+
narrative: "Appended dream.",
331+
nowMs: Date.parse("2026-04-05T03:00:00Z"),
332+
timezone: "UTC",
333+
}),
334+
).rejects.toThrow("replace failed");
335+
336+
expect(renameSpy).toHaveBeenCalledOnce();
337+
await expect(fs.readFile(dreamsPath, "utf-8")).resolves.toBe("# Existing\n");
338+
});
339+
340+
it("preserves restrictive dreams file permissions across atomic replace", async () => {
341+
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
342+
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
343+
await fs.writeFile(dreamsPath, "# Existing\n", { encoding: "utf-8", mode: 0o600 });
344+
await fs.chmod(dreamsPath, 0o600);
345+
346+
await appendNarrativeEntry({
347+
workspaceDir,
348+
narrative: "Appended dream.",
349+
nowMs: Date.parse("2026-04-05T03:00:00Z"),
350+
timezone: "UTC",
351+
});
352+
353+
const stat = await fs.stat(dreamsPath);
354+
expect(stat.mode & 0o777).toBe(0o600);
355+
});
356+
357+
it("surfaces temp cleanup failure after atomic replace error", async () => {
358+
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
359+
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
360+
await fs.writeFile(dreamsPath, "# Existing\n", "utf-8");
361+
vi.spyOn(fs, "rename").mockRejectedValueOnce(
362+
Object.assign(new Error("replace failed"), { code: "ENOSPC" }),
363+
);
364+
vi.spyOn(fs, "rm").mockRejectedValueOnce(
365+
Object.assign(new Error("cleanup failed"), { code: "EACCES" }),
366+
);
367+
368+
await expect(
369+
appendNarrativeEntry({
370+
workspaceDir,
371+
narrative: "Appended dream.",
372+
nowMs: Date.parse("2026-04-05T03:00:00Z"),
373+
timezone: "UTC",
374+
}),
375+
).rejects.toThrow("cleanup also failed");
376+
});
315377
});
316378

317379
describe("generateAndAppendDreamNarrative", () => {
@@ -341,6 +403,8 @@ describe("generateAndAppendDreamNarrative", () => {
341403
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
342404
const subagent = createMockSubagent("The repository whispered of forgotten endpoints.");
343405
const logger = createMockLogger();
406+
const nowMs = Date.parse("2026-04-05T03:00:00Z");
407+
const expectedSessionKey = `dreaming-narrative-light-${nowMs}`;
344408

345409
await generateAndAppendDreamNarrative({
346410
subagent,
@@ -349,13 +413,15 @@ describe("generateAndAppendDreamNarrative", () => {
349413
phase: "light",
350414
snippets: ["API endpoints need authentication"],
351415
},
352-
nowMs: Date.parse("2026-04-05T03:00:00Z"),
416+
nowMs,
353417
timezone: "UTC",
354418
logger,
355419
});
356420

357421
expect(subagent.run).toHaveBeenCalledOnce();
358422
expect(subagent.run.mock.calls[0][0]).toMatchObject({
423+
idempotencyKey: expectedSessionKey,
424+
sessionKey: expectedSessionKey,
359425
deliver: false,
360426
});
361427
expect(subagent.waitForRun).toHaveBeenCalledOnce();

extensions/memory-core/src/dreaming-narrative.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
66

77
type SubagentSurface = {
88
run: (params: {
9+
idempotencyKey: string;
910
sessionKey: string;
1011
message: string;
1112
extraSystemPrompt?: string;
@@ -277,12 +278,26 @@ async function assertSafeDreamsPath(dreamsPath: string): Promise<void> {
277278

278279
async function writeDreamsFileAtomic(dreamsPath: string, content: string): Promise<void> {
279280
await assertSafeDreamsPath(dreamsPath);
281+
const existing = await fs.stat(dreamsPath).catch((err: NodeJS.ErrnoException) => {
282+
if (err.code === "ENOENT") {
283+
return null;
284+
}
285+
throw err;
286+
});
287+
const mode = existing?.mode ?? 0o600;
280288
const tempPath = `${dreamsPath}.${process.pid}.${Date.now()}.tmp`;
281-
await fs.writeFile(tempPath, content, { encoding: "utf-8", flag: "wx" });
289+
await fs.writeFile(tempPath, content, { encoding: "utf-8", flag: "wx", mode });
290+
await fs.chmod(tempPath, mode).catch(() => undefined);
282291
try {
283292
await fs.rename(tempPath, dreamsPath);
293+
await fs.chmod(dreamsPath, mode).catch(() => undefined);
284294
} catch (err) {
285-
await fs.rm(tempPath, { force: true }).catch(() => {});
295+
const cleanupError = await fs.rm(tempPath, { force: true }).catch((rmErr) => rmErr);
296+
if (cleanupError) {
297+
throw new Error(
298+
`Atomic DREAMS.md write failed (${formatErrorMessage(err)}); cleanup also failed (${formatErrorMessage(cleanupError)})`,
299+
);
300+
}
286301
throw err;
287302
}
288303
}
@@ -409,7 +424,7 @@ export async function appendNarrativeEntry(params: {
409424
}
410425
}
411426

412-
await fs.writeFile(dreamsPath, updated.endsWith("\n") ? updated : `${updated}\n`, "utf-8");
427+
await writeDreamsFileAtomic(dreamsPath, updated.endsWith("\n") ? updated : `${updated}\n`);
413428
return dreamsPath;
414429
}
415430

@@ -434,6 +449,7 @@ export async function generateAndAppendDreamNarrative(params: {
434449

435450
try {
436451
const { runId } = await params.subagent.run({
452+
idempotencyKey: sessionKey,
437453
sessionKey,
438454
message,
439455
extraSystemPrompt: NARRATIVE_SYSTEM_PROMPT,

0 commit comments

Comments
 (0)