fix(ci): restore main checks after bulk merges

This commit is contained in:
Peter Steinberger 2026-02-16 23:47:27 +00:00
parent 8c241449f5
commit 12a947223b
8 changed files with 71 additions and 79 deletions

View File

@ -77,6 +77,7 @@ Text + native (when enabled):
- `/allowlist` (list/add/remove allowlist entries)
- `/approve <id> 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 <id|#|all>` (immediately abort one or all running sub-agents for this session; no confirmation message)

View File

@ -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",

View File

@ -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<unknown>;
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
timeoutAt?: number;
timeoutMs?: number;
staleMs: number;
timer?: ReturnType<typeof setTimeout>;
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<void> {
const queue = LOCK_QUEUES.get(storePath);
if (!queue || queue.running) {
@ -719,21 +706,12 @@ async function drainSessionStoreLockQueue(storePath: string): Promise<void> {
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<T>(
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<T>((resolve, reject) => {
@ -797,23 +774,10 @@ async function withSessionStoreLock<T>(
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);
});

View File

@ -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;
}

View File

@ -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("✅");
});
});

View File

@ -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

View File

@ -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,

View File

@ -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",