diff --git a/extensions/browser/src/browser/output-files.ts b/extensions/browser/src/browser/output-files.ts index 442236f860e..9b5436de0e1 100644 --- a/extensions/browser/src/browser/output-files.ts +++ b/extensions/browser/src/browser/output-files.ts @@ -21,6 +21,11 @@ export async function writeExternalFileWithinOutputRoot(params: { rootDir, path: outputPath, write: params.write, + }).catch((err: unknown) => { + if (err instanceof Error && /file not found/i.test(err.message)) { + throw new Error("output directory changed while writing file"); + } + throw err; }); return result.path; } diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 3ad9e08f0f9..9e0ead5762d 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -1680,7 +1680,8 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); - expect(draftStream.update).toHaveBeenCalledWith("Clawing...\n🧩 First\n🧩 Second\n🧩 Third"); + expect(draftStream.update).toHaveBeenNthCalledWith(1, "Clawing...\n🧩 First\n🧩 Second"); + expect(draftStream.update).toHaveBeenNthCalledWith(2, "🧩 First\n🧩 Second\n🧩 Third"); }); it("skips empty apply_patch starts and renders the patch summary", async () => { diff --git a/extensions/msteams/src/reply-stream-controller.test.ts b/extensions/msteams/src/reply-stream-controller.test.ts index 316e0f059ea..3d4ebe9b4fb 100644 --- a/extensions/msteams/src/reply-stream-controller.test.ts +++ b/extensions/msteams/src/reply-stream-controller.test.ts @@ -320,9 +320,7 @@ describe("createTeamsReplyStreamController", () => { expect(ctrl.shouldSuppressDefaultToolProgressMessages()).toBe(true); expect(ctrl.shouldStreamPreviewToolProgress()).toBe(true); - expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith( - "Working\n- tool: exec", - ); + expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith("- tool: exec"); }); it("suppresses Teams default progress messages without stream lines when tool progress is disabled", async () => { diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts index 9893047a113..6e508a86484 100644 --- a/src/agents/session-write-lock.test.ts +++ b/src/agents/session-write-lock.test.ts @@ -11,16 +11,6 @@ let resetSessionWriteLockStateForTest: typeof import("./session-write-lock.js"). let resolveSessionLockMaxHoldFromTimeout: typeof import("./session-write-lock.js").resolveSessionLockMaxHoldFromTimeout; let resolveSessionWriteLockAcquireTimeoutMs: typeof import("./session-write-lock.js").resolveSessionWriteLockAcquireTimeoutMs; -vi.mock("../shared/pid-alive.js", async () => { - const original = - await vi.importActual("../shared/pid-alive.js"); - return { - ...original, - // Keep liveness checks real; only pin process start time for PID recycle coverage. - getProcessStartTime: (pid: number) => (pid === process.pid ? FAKE_STARTTIME : null), - }; -}); - async function expectLockRemovedOnlyAfterFinalRelease(params: { lockPath: string; firstLock: { release: () => Promise }; @@ -142,6 +132,12 @@ describe("acquireSessionWriteLock", () => { resetSessionWriteLockStateForTest(); vi.clearAllMocks(); }); + + function pinCurrentProcessStartTimeForTest(): void { + __testing.setProcessStartTimeResolverForTest((pid) => + pid === process.pid ? FAKE_STARTTIME : null, + ); + } it("reuses locks across symlinked session paths", async () => { await withSymlinkedSessionPaths( async ({ sessionReal, sessionLink, realLockPath, linkLockPath }) => { @@ -418,6 +414,7 @@ describe("acquireSessionWriteLock", () => { }); it("cleans untracked current-process .jsonl lock files with matching starttime", async () => { + pinCurrentProcessStartTimeForTest(); const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-")); const sessionsDir = path.join(root, "sessions"); await fs.mkdir(sessionsDir, { recursive: true }); @@ -490,6 +487,7 @@ describe("acquireSessionWriteLock", () => { return; } await withTempSessionLockFile(async ({ sessionFile, lockPath }) => { + pinCurrentProcessStartTimeForTest(); // Write a lock with a live PID (current process) but a wrong starttime, // simulating PID recycling: the PID is alive but belongs to a different // process than the one that created the lock. @@ -511,6 +509,7 @@ describe("acquireSessionWriteLock", () => { it("reclaims untracked current-process lock files with matching starttime", async () => { await withTempSessionLockFile(async ({ sessionFile, lockPath }) => { + pinCurrentProcessStartTimeForTest(); await writeCurrentProcessLock(lockPath, { starttime: FAKE_STARTTIME }); await expectCurrentPidOwnsLock({ sessionFile, timeoutMs: 500 }); @@ -526,6 +525,7 @@ describe("acquireSessionWriteLock", () => { }); it("does not reclaim active in-process lock files with matching starttime", async () => { + pinCurrentProcessStartTimeForTest(); await expectActiveInProcessLockIsNotReclaimed({ legacyStarttime: FAKE_STARTTIME }); }); diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index aaf1888864c..ae20cde9268 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -61,6 +61,7 @@ type LockInspectionDetails = Pick< >; const SESSION_LOCKS = createFileLockManager("openclaw.session-write-lock"); +let resolveProcessStartTimeForLock = getProcessStartTime; function isFileLockError(error: unknown, code: string): boolean { return (error as { code?: unknown } | null)?.code === code; @@ -312,7 +313,7 @@ function inspectLockPayload( const pidRecycled = pidAlive && pid !== null && storedStarttime !== null ? (() => { - const currentStarttime = getProcessStartTime(pid); + const currentStarttime = resolveProcessStartTimeForLock(pid); return currentStarttime !== null && currentStarttime !== storedStarttime; })() : false; @@ -419,7 +420,7 @@ function shouldTreatAsOrphanSelfLock(params: { return params.reclaimLockWithoutStarttime; } - const currentStarttime = getProcessStartTime(process.pid); + const currentStarttime = resolveProcessStartTimeForLock(process.pid); return currentStarttime !== null && currentStarttime === storedStarttime; } @@ -543,7 +544,7 @@ export async function acquireSessionWriteLock(params: { metadata: { maxHoldMs }, payload: () => { const createdAt = new Date().toISOString(); - const starttime = getProcessStartTime(process.pid); + const starttime = resolveProcessStartTimeForLock(process.pid); const lockPayload: LockFilePayload = { pid: process.pid, createdAt }; if (starttime !== null) { lockPayload.starttime = starttime; @@ -591,6 +592,9 @@ export const __testing = { handleTerminationSignal, releaseAllLocksSync, runLockWatchdogCheck, + setProcessStartTimeResolverForTest(resolver: ((pid: number) => number | null) | null): void { + resolveProcessStartTimeForLock = resolver ?? getProcessStartTime; + }, }; export async function drainSessionWriteLockStateForTest(): Promise { @@ -603,4 +607,5 @@ export function resetSessionWriteLockStateForTest(): void { releaseAllLocksSync(); stopWatchdogTimer(); unregisterCleanupHandlers(); + resolveProcessStartTimeForLock = getProcessStartTime; } diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index e78c0cd3401..bfcfbd121d3 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -69,7 +69,7 @@ function extractProviderFromModelRef(value: string): string | null { } function hasConfiguredEmbeddedHarnessRuntime(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - return collectConfiguredAgentHarnessRuntimes(cfg, env).length > 0; + return collectConfiguredAgentHarnessRuntimes(cfg, env, { includeEnvRuntime: false }).length > 0; } function resolveAgentHarnessOwnerPluginIds( @@ -641,7 +641,9 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { } } - for (const runtime of collectConfiguredAgentHarnessRuntimes(params.config, params.env)) { + for (const runtime of collectConfiguredAgentHarnessRuntimes(params.config, params.env, { + includeEnvRuntime: false, + })) { const pluginIds = resolveAgentHarnessOwnerPluginIds(params.registry, runtime); for (const pluginId of pluginIds) { changes.push({ diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index 8beaf901f68..6f4421b3216 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -629,7 +629,10 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { rootConfig: activationSourceConfig, }; const requiredAgentHarnessRuntimes = new Set( - collectConfiguredAgentHarnessRuntimes(activationSourceConfig, params.env), + collectConfiguredAgentHarnessRuntimes(activationSourceConfig, params.env, { + includeEnvRuntime: false, + includeLegacyAgentRuntimes: false, + }), ); const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config); const manifestLookup = createManifestRegistryLookup(params.manifestRegistry);