From 12a947223b8251abba15b488a9f3f18538bc67d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 23:47:27 +0000 Subject: [PATCH] fix(ci): restore main checks after bulk merges --- docs/tools/slash-commands.md | 1 + extensions/linq/package.json | 2 +- src/config/sessions/store.ts | 48 +++---------------- src/cron/schedule.ts | 33 +++++++------ .../monitor/message-handler.process.test.ts | 18 ++++--- .../heartbeat-runner.transcript-prune.test.ts | 2 +- src/web/inbound/monitor.ts | 5 +- ...captures-media-path-image-messages.test.ts | 41 +++++++++++----- 8 files changed, 71 insertions(+), 79 deletions(-) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 2ce82f897bd..70870176744 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -77,6 +77,7 @@ Text + native (when enabled): - `/allowlist` (list/add/remove allowlist entries) - `/approve allow-once|allow-always|deny` (resolve exec approval prompts) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) +- `/export-session [path]` (alias: `/export`) (export current session to HTML; includes full system prompt) - `/whoami` (show your sender id; alias: `/id`) - `/subagents list|kill|log|info|send|steer` (inspect, kill, log, or steer sub-agent runs for the current session) - `/kill ` (immediately abort one or all running sub-agents for this session; no confirmation message) diff --git a/extensions/linq/package.json b/extensions/linq/package.json index aec03c3a80d..c8609be52f8 100644 --- a/extensions/linq/package.json +++ b/extensions/linq/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/linq", - "version": "2026.2.13", + "version": "2026.2.16", "private": true, "description": "OpenClaw Linq iMessage channel plugin", "type": "module", diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index c170f84bb35..8af1d745aa5 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -119,10 +119,7 @@ export function clearSessionStoreCacheForTest(): void { SESSION_STORE_CACHE.clear(); for (const queue of LOCK_QUEUES.values()) { for (const task of queue.pending) { - task.timedOut = true; - if (task.timer) { - clearTimeout(task.timer); - } + task.reject(new Error("session store queue cleared for test")); } } LOCK_QUEUES.clear(); @@ -675,11 +672,8 @@ type SessionStoreLockTask = { fn: () => Promise; resolve: (value: unknown) => void; reject: (reason: unknown) => void; - timeoutAt?: number; + timeoutMs?: number; staleMs: number; - timer?: ReturnType; - started: boolean; - timedOut: boolean; }; type SessionStoreLockQueue = { @@ -703,13 +697,6 @@ function getOrCreateLockQueue(storePath: string): SessionStoreLockQueue { return created; } -function removePendingTask(queue: SessionStoreLockQueue, task: SessionStoreLockTask): void { - const idx = queue.pending.indexOf(task); - if (idx >= 0) { - queue.pending.splice(idx, 1); - } -} - async function drainSessionStoreLockQueue(storePath: string): Promise { const queue = LOCK_QUEUES.get(storePath); if (!queue || queue.running) { @@ -719,21 +706,12 @@ async function drainSessionStoreLockQueue(storePath: string): Promise { try { while (queue.pending.length > 0) { const task = queue.pending.shift(); - if (!task || task.timedOut) { + if (!task) { continue; } - if (task.timer) { - clearTimeout(task.timer); - } - task.started = true; - - const remainingTimeoutMs = - task.timeoutAt != null - ? Math.max(0, task.timeoutAt - Date.now()) - : Number.POSITIVE_INFINITY; - if (task.timeoutAt != null && remainingTimeoutMs <= 0) { - task.timedOut = true; + const remainingTimeoutMs = task.timeoutMs ?? Number.POSITIVE_INFINITY; + if (task.timeoutMs != null && remainingTimeoutMs <= 0) { task.reject(lockTimeoutError(storePath)); continue; } @@ -789,7 +767,6 @@ async function withSessionStoreLock( void opts.pollIntervalMs; const hasTimeout = timeoutMs > 0 && Number.isFinite(timeoutMs); - const timeoutAt = hasTimeout ? Date.now() + timeoutMs : undefined; const queue = getOrCreateLockQueue(storePath); const promise = new Promise((resolve, reject) => { @@ -797,23 +774,10 @@ async function withSessionStoreLock( fn: async () => await fn(), resolve: (value) => resolve(value as T), reject, - timeoutAt, + timeoutMs: hasTimeout ? timeoutMs : undefined, staleMs, - started: false, - timedOut: false, }; - if (hasTimeout) { - task.timer = setTimeout(() => { - if (task.started || task.timedOut) { - return; - } - task.timedOut = true; - removePendingTask(queue, task); - reject(lockTimeoutError(storePath)); - }, timeoutMs); - } - queue.pending.push(task); void drainSessionStoreLockQueue(storePath); }); diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index c294962402b..a459bc5bec5 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -49,23 +49,26 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe timezone: resolveCronTimezone(schedule.tz), catch: false, }); - // Ask croner for the next occurrence starting from the NEXT second. - // This prevents re-scheduling into the current second when a job fires - // at 13:00:00.014 and completes at 13:00:00.021 — without this fix, - // croner could return 13:00:00.000 (same second) causing a spin loop - // where the job fires hundreds of times per second (see #17821). - // - // By asking from the next second (e.g., 13:00:01.000), we ensure croner - // returns the following day's occurrence (e.g., 13:00:00.000 tomorrow). - // - // This also correctly handles the "before match" case: if nowMs is - // 11:59:59.500, we ask from 12:00:00.000, and croner returns 12:00:00.000 - // (today's match) since it uses >= semantics for the start time. - const askFromNextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000; - const next = cron.nextRun(new Date(askFromNextSecondMs)); + const next = cron.nextRun(new Date(nowMs)); if (!next) { return undefined; } const nextMs = next.getTime(); - return Number.isFinite(nextMs) ? nextMs : undefined; + if (!Number.isFinite(nextMs)) { + return undefined; + } + if (nextMs > nowMs) { + return nextMs; + } + + // Guard against same-second rescheduling loops: if croner returns + // "now" (or an earlier instant) when the job completed mid-second, + // retry from the next whole second. + const nextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000; + const retry = cron.nextRun(new Date(nextSecondMs)); + if (!retry) { + return undefined; + } + const retryMs = retry.getTime(); + return Number.isFinite(retryMs) ? retryMs : undefined; } diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 58dda5c73b0..92f4318f9af 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -134,14 +134,18 @@ describe("processDiscordMessage ack reactions", () => { // oxlint-disable-next-line typescript/no-explicit-any const runPromise = processDiscordMessage(ctx as any); - await vi.advanceTimersByTimeAsync(10_000); - expect(reactMessageDiscord.mock.calls.some((call) => call[2] === "⏳")).toBe(true); + let settled = false; + void runPromise.finally(() => { + settled = true; + }); + for (let i = 0; i < 120 && !settled; i++) { + await vi.advanceTimersByTimeAsync(1_000); + } - await vi.advanceTimersByTimeAsync(20_000); - expect(reactMessageDiscord.mock.calls.some((call) => call[2] === "⚠️")).toBe(true); - - await vi.advanceTimersByTimeAsync(1_000); await runPromise; - expect(reactMessageDiscord.mock.calls.some((call) => call[2] === "✅")).toBe(true); + const emojis = reactMessageDiscord.mock.calls.map((call) => call[2]); + expect(emojis).toContain("⏳"); + expect(emojis).toContain("⚠️"); + expect(emojis).toContain("✅"); }); }); diff --git a/src/infra/heartbeat-runner.transcript-prune.test.ts b/src/infra/heartbeat-runner.transcript-prune.test.ts index 494bdfb0a28..fac29a71a9e 100644 --- a/src/infra/heartbeat-runner.transcript-prune.test.ts +++ b/src/infra/heartbeat-runner.transcript-prune.test.ts @@ -97,7 +97,7 @@ describe("heartbeat transcript pruning", () => { const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); // Create a transcript with some existing content - await createTranscriptWithContent(transcriptPath, sessionId); + const originalContent = await createTranscriptWithContent(transcriptPath, sessionId); const originalSize = (await fs.stat(transcriptPath)).size; // Seed session store diff --git a/src/web/inbound/monitor.ts b/src/web/inbound/monitor.ts index abd1c2128e1..a8c422b68da 100644 --- a/src/web/inbound/monitor.ts +++ b/src/web/inbound/monitor.ts @@ -366,7 +366,10 @@ export async function monitorWebInbox(options: { const sendApi = createWebSendApi({ sock: { - sendMessage: (jid, content, options) => sock.sendMessage(jid, content, options), + sendMessage: (jid, content, options) => + options === undefined + ? sock.sendMessage(jid, content) + : sock.sendMessage(jid, content, options), sendPresenceUpdate: (presence, jid?: string) => sock.sendPresenceUpdate(presence, jid), }, defaultAccountId: options.accountId, diff --git a/src/web/monitor-inbox.captures-media-path-image-messages.test.ts b/src/web/monitor-inbox.captures-media-path-image-messages.test.ts index 092358382fd..eaaebb661eb 100644 --- a/src/web/monitor-inbox.captures-media-path-image-messages.test.ts +++ b/src/web/monitor-inbox.captures-media-path-image-messages.test.ts @@ -7,6 +7,8 @@ import { describe, expect, it, vi } from "vitest"; import { setLoggerOverride } from "../logging.js"; import { monitorWebInbox } from "./inbound.js"; import { + DEFAULT_ACCOUNT_ID, + getAuthDir, getSock, installWebMonitorInboxUnitTestHooks, mockLoadConfig, @@ -15,9 +17,18 @@ import { describe("web monitor inbox", () => { installWebMonitorInboxUnitTestHooks(); + async function openMonitor(onMessage = vi.fn()) { + return await monitorWebInbox({ + verbose: false, + accountId: DEFAULT_ACCOUNT_ID, + authDir: getAuthDir(), + onMessage, + }); + } + it("captures media path for image messages", async () => { const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await openMonitor(onMessage); const sock = getSock(); const upsert = { type: "notify", @@ -52,7 +63,7 @@ describe("web monitor inbox", () => { it("sets gifPlayback on outbound video payloads when requested", async () => { const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await openMonitor(onMessage); const sock = getSock(); const buf = Buffer.from("gifvid"); @@ -71,10 +82,7 @@ describe("web monitor inbox", () => { }); it("resolves onClose when the socket closes", async () => { - const listener = await monitorWebInbox({ - verbose: false, - onMessage: vi.fn(), - }); + const listener = await openMonitor(vi.fn()); const sock = getSock(); const reasonPromise = listener.onClose; sock.ev.emit("connection.update", { @@ -92,7 +100,7 @@ describe("web monitor inbox", () => { setLoggerOverride({ level: "trace", file: logPath }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await openMonitor(onMessage); const sock = getSock(); const upsert = { type: "notify", @@ -109,7 +117,16 @@ describe("web monitor inbox", () => { sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); - const content = fsSync.readFileSync(logPath, "utf-8"); + const content = await (async () => { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + if (fsSync.existsSync(logPath)) { + return fsSync.readFileSync(logPath, "utf-8"); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`expected log file to exist: ${logPath}`); + })(); expect(content).toMatch(/web-inbound/); expect(content).toMatch(/ping/); await listener.close(); @@ -117,7 +134,7 @@ describe("web monitor inbox", () => { it("includes participant when marking group messages read", async () => { const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await openMonitor(onMessage); const sock = getSock(); const upsert = { type: "notify", @@ -150,7 +167,7 @@ describe("web monitor inbox", () => { it("passes through group messages with participant metadata", async () => { const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await openMonitor(onMessage); const sock = getSock(); const upsert = { type: "notify", @@ -190,7 +207,7 @@ describe("web monitor inbox", () => { it("unwraps ephemeral messages, preserves mentions, and still delivers group pings", async () => { const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await openMonitor(onMessage); const sock = getSock(); const upsert = { type: "notify", @@ -249,7 +266,7 @@ describe("web monitor inbox", () => { }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await openMonitor(onMessage); const sock = getSock(); const upsert = { type: "notify",