Compare commits

..

9 Commits

Author SHA1 Message Date
Peter Steinberger
44e5b62c27 fix(macos): harden shell executor timeouts 2026-04-11 03:58:20 +01:00
George Zhang
9a4a9a5993 Heartbeat: spread interval runs across stable phases (#64560)
Merged via squash.

Prepared head SHA: 774ede6408
Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com>
Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com>
Reviewed-by: @odysseus0
2026-04-10 19:40:21 -07:00
Peter Steinberger
e11d902b7d fix(ci): stop telegram debounce media leak 2026-04-11 03:36:48 +01:00
Peter Steinberger
df7e61b546 fix(ci): align compact count assertion 2026-04-11 03:32:03 +01:00
Peter Steinberger
5b2888e1fd test(install): pin smoke docker platform 2026-04-11 03:31:47 +01:00
Peter Steinberger
421338f585 test(install): quiet smoke npm output 2026-04-11 03:31:47 +01:00
Peter Steinberger
05659cfbc3 test: harden macOS Parallels permission check 2026-04-11 03:30:01 +01:00
Peter Steinberger
896eb888a8 fix(ci): align target session alias fixture 2026-04-11 03:27:20 +01:00
Peter Steinberger
05521242cd fix(ci): stabilize agentic compact tests 2026-04-11 03:25:32 +01:00
21 changed files with 445 additions and 118 deletions

View File

@@ -115,12 +115,20 @@ Docs: https://docs.openclaw.ai
- Daemon/gateway: prevent systemd restart storms on configuration errors by exiting with `EX_CONFIG` and adding generated unit restart-prevention guards. (#63913) Thanks @neo1027144-creator.
- Agents/exec: prevent gateway crash ("Agent listener invoked outside active run") when a subagent exec tool produces stdout/stderr after the agent run has ended or been aborted. (#62821) Thanks @openperf.
- Gateway/OpenAI compat: return real `usage` for non-stream `/v1/chat/completions` responses, emit the final usage chunk when `stream_options.include_usage=true`, and bound usage-gated stream finalization after lifecycle end. (#62986) Thanks @Lellansin.
- Matrix/migration: keep packaged warning-only crypto migrations from being misclassified as actionable when only helper chunks are present, so startup and doctor stay on the warning-only path instead of creating unnecessary migration snapshots. (#64373) Thanks @gumadeiras.
- Matrix/ACP thread bindings: preserve canonical room casing and parent conversation routing during ACP session spawn so mixed-case room ids bind correctly from top-level rooms and existing Matrix threads. (#64343) Thanks @gumadeiras.
- Agents/subagents: deduplicate delivered completion announces so retry or re-entry cleanup does not inject duplicate internal-context completion turns into the parent session. (#61525) Thanks @100yenadmin.
- Agents/exec: keep sandboxed `tools.exec.host=auto` sessions from honoring per-call `host=node` or `host=gateway` overrides while a sandbox runtime is active, and stop advertising node routing in that state so exec stays on the sandbox host. (#63880)
- Agents/subagents: preserve archived delete-mode runs until `sessions.delete` succeeds and prevent overlapping archive sweeps from duplicating in-flight cleanup attempts. (#61801) Thanks @100yenadmin.
- Cron/isolated agent: run scheduled agent turns as non-owner senders so owner-only tools stay unavailable during cron execution. (#63878)
- Discord/sandbox: include `image` in sandbox media param normalization so Discord event cover images cannot bypass sandbox path rewriting. (#64377) Thanks @mmaps.
- Agents/exec: extend exec completion detection to cover local background exec formats so the owner-downgrade fires correctly for all exec paths. (#64376) Thanks @mmaps.
- Security/dependencies: pin axios to 1.15.0 and add a plugin install dependency denylist that blocks known malicious packages before install. (#63891) Thanks @mmaps.
- Browser/security: apply three-phase interaction navigation guard to pressKey and type(submit) so delayed JS redirects from keypress cannot bypass SSRF policy. (#63889) Thanks @mmaps.
- Browser/security: guard existing-session Chrome MCP interaction routes with SSRF post-checks so delayed navigation from click, type, press, and evaluate cannot bypass the configured policy. (#64370) Thanks @eleqtrizit.
- Browser/security: default browser SSRF policy to strict mode so unconfigured installs block private-network navigation, and align external-content marker span mapping so ZWS-injected boundary spoofs are fully sanitized. (#63885) Thanks @eleqtrizit.
- Browser/security: apply SSRF navigation policy to subframe document navigations so iframe-targeted private-network hops are blocked without quarantining the parent page. (#64371) Thanks @eleqtrizit.
- Hooks/security: mark agent hook system events as untrusted and sanitize hook display names before cron metadata reuse. (#64372) Thanks @eleqtrizit.
- Daemon/launchd: keep `openclaw gateway stop` persistent without uninstalling the macOS LaunchAgent, re-enable it on explicit restart or repair, and harden launchd label handling. (#64447) Thanks @ngutman.
- Plugins/context engines: preserve `plugins.slots.contextEngine` through normalization and keep explicitly selected workspace context-engine plugins enabled, so loader diagnostics and plugin activation stop dropping that slot selection. (#64192) Thanks @hclsys.
@@ -131,6 +139,7 @@ Docs: https://docs.openclaw.ai
- Media/security: honor sender-scoped `toolsBySender` policy for outbound host-media reads so denied senders cannot trigger host file disclosure via attachment hydration. (#64459) Thanks @eleqtrizit.
- Browser/security: reject strict-policy hostname navigation unless the hostname is an explicit allowlist exception or IP literal, and route CDP HTTP discovery through the pinned SSRF fetch path. (#64367) Thanks @eleqtrizit.
- Models/vLLM: ignore empty `tool_calls` arrays from reasoning-model OpenAI-compatible replies, reset false `toolUse` stop reasons when no actual tool calls were parsed, and stop sending `tool_choice` unless tools are present so vLLM reasoning responses no longer hang indefinitely. (#61197, #61534) Thanks @balajisiva.
- Heartbeat/scheduling: spread interval heartbeats across stable per-agent phases derived from gateway identity, so provider traffic is distributed more uniformly across the configured interval instead of clustering around startup-relative times. (#64560) Thanks @odysseus0.
## 2026.4.9

View File

@@ -11,6 +11,40 @@ enum ShellExecutor {
var errorMessage: String?
}
private final class CompletionBox: @unchecked Sendable {
private let lock = NSLock()
private var finished = false
private let continuation: CheckedContinuation<ShellResult, Never>
init(continuation: CheckedContinuation<ShellResult, Never>) {
self.continuation = continuation
}
func finish(_ result: ShellResult) {
self.lock.lock()
defer { self.lock.unlock() }
guard !self.finished else { return }
self.finished = true
self.continuation.resume(returning: result)
}
}
private static func completedResult(
status: Int,
outTask: Task<Data, Never>,
errTask: Task<Data, Never>) async -> ShellResult
{
let out = await outTask.value
let err = await errTask.value
return ShellResult(
stdout: String(bytes: out, encoding: .utf8) ?? "",
stderr: String(bytes: err, encoding: .utf8) ?? "",
exitCode: status,
timedOut: false,
success: status == 0,
errorMessage: status == 0 ? nil : "exit \(status)")
}
static func runDetailed(
command: [String],
cwd: String?,
@@ -38,6 +72,53 @@ enum ShellExecutor {
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
let outTask = Task { stdoutPipe.fileHandleForReading.readToEndSafely() }
let errTask = Task { stderrPipe.fileHandleForReading.readToEndSafely() }
if let timeout, timeout > 0 {
return await withCheckedContinuation { continuation in
let completion = CompletionBox(continuation: continuation)
process.terminationHandler = { terminatedProcess in
let status = Int(terminatedProcess.terminationStatus)
Task {
let result = await self.completedResult(
status: status,
outTask: outTask,
errTask: errTask)
completion.finish(result)
}
}
do {
try process.run()
} catch {
completion.finish(
ShellResult(
stdout: "",
stderr: "",
exitCode: nil,
timedOut: false,
success: false,
errorMessage: "failed to start: \(error.localizedDescription)"))
return
}
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + timeout) {
guard process.isRunning else { return }
process.terminate()
completion.finish(
ShellResult(
stdout: "",
stderr: "",
exitCode: nil,
timedOut: true,
success: false,
errorMessage: "timeout"))
}
}
}
do {
try process.run()
} catch {
@@ -50,48 +131,11 @@ enum ShellExecutor {
errorMessage: "failed to start: \(error.localizedDescription)")
}
let outTask = Task { stdoutPipe.fileHandleForReading.readToEndSafely() }
let errTask = Task { stderrPipe.fileHandleForReading.readToEndSafely() }
let waitTask = Task { () -> ShellResult in
process.waitUntilExit()
let out = await outTask.value
let err = await errTask.value
let status = Int(process.terminationStatus)
return ShellResult(
stdout: String(bytes: out, encoding: .utf8) ?? "",
stderr: String(bytes: err, encoding: .utf8) ?? "",
exitCode: status,
timedOut: false,
success: status == 0,
errorMessage: status == 0 ? nil : "exit \(status)")
}
if let timeout, timeout > 0 {
let nanos = UInt64(timeout * 1_000_000_000)
return await withTaskGroup(of: ShellResult.self) { group in
group.addTask { await waitTask.value }
group.addTask {
try? await Task.sleep(nanoseconds: nanos)
guard process.isRunning else {
return await waitTask.value
}
process.terminate()
return ShellResult(
stdout: "",
stderr: "",
exitCode: nil,
timedOut: true,
success: false,
errorMessage: "timeout")
}
let first = await group.next()!
group.cancelAll()
return first
}
}
return await waitTask.value
process.waitUntilExit()
return await self.completedResult(
status: Int(process.terminationStatus),
outTask: outTask,
errTask: errTask)
}
static func run(command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> Response {

View File

@@ -57,7 +57,7 @@ available.
After that, restart the gateway:
```bash
openclaw gateway
node scripts/run-node.mjs gateway --profile dev
```
To inspect it live in a conversation:
@@ -102,7 +102,7 @@ Start with this in `openclaw.json`:
Then restart the gateway:
```bash
openclaw gateway
node scripts/run-node.mjs gateway --profile dev
```
What this means:

View File

@@ -1398,17 +1398,24 @@ describe("createTelegramBot", () => {
},
});
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
const mediaFetch = vi.fn(
async () =>
new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { "content-type": "image/png" },
}),
);
const ssrfMock = mockPinnedHostnameResolution();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
const replyDelivered = waitForReplyCalls(1);
createTelegramBot({ token: "tok" });
createTelegramBot({
token: "tok",
telegramTransport: {
fetch: mediaFetch as typeof fetch,
sourceFetch: mediaFetch as typeof fetch,
},
});
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
@@ -1465,9 +1472,10 @@ describe("createTelegramBot", () => {
expect(getFileSpy).toHaveBeenCalledTimes(1);
expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1");
expect(mediaFetch).toHaveBeenCalledTimes(1);
} finally {
setTimeoutSpy.mockRestore();
fetchSpy.mockRestore();
ssrfMock.mockRestore();
}
});

View File

@@ -19,7 +19,7 @@ verify_installed_cli() {
fi
if [[ -z "$cmd_path" ]]; then
npm_root="$(npm root -g 2>/dev/null || true)"
npm_root="$(quiet_npm root -g 2>/dev/null || true)"
if [[ -n "$npm_root" && -f "$npm_root/$package_name/dist/entry.js" ]]; then
entry_path="$npm_root/$package_name/dist/entry.js"
fi

View File

@@ -12,3 +12,13 @@ extract_openclaw_semver() {
)"
printf '%s' "${parsed#v}"
}
quiet_npm() {
npm \
--loglevel=error \
--no-update-notifier \
--no-fund \
--no-audit \
--no-progress \
"$@"
}

View File

@@ -47,7 +47,7 @@ elif [[ "$MODELS_MODE" == "anthropic" && -z "$ANTHROPIC_API_TOKEN" && -z "$ANTHR
fi
echo "==> Resolve npm versions"
EXPECTED_VERSION="$(npm view "openclaw@${INSTALL_TAG}" version)"
EXPECTED_VERSION="$(quiet_npm view "openclaw@${INSTALL_TAG}" version)"
if [[ -z "$EXPECTED_VERSION" || "$EXPECTED_VERSION" == "undefined" || "$EXPECTED_VERSION" == "null" ]]; then
echo "ERROR: unable to resolve openclaw@${INSTALL_TAG} version" >&2
exit 2
@@ -55,9 +55,8 @@ fi
if [[ -n "$E2E_PREVIOUS_VERSION" ]]; then
PREVIOUS_VERSION="$E2E_PREVIOUS_VERSION"
else
PREVIOUS_VERSION="$(node - <<'NODE'
const { execSync } = require("node:child_process");
const versions = JSON.parse(execSync("npm view openclaw versions --json", { encoding: "utf8" }));
PREVIOUS_VERSION="$(VERSIONS_JSON="$(quiet_npm view openclaw versions --json)" node - <<'NODE'
const versions = JSON.parse(process.env.VERSIONS_JSON || "[]");
if (!Array.isArray(versions) || versions.length === 0) process.exit(1);
process.stdout.write(versions.length >= 2 ? versions[versions.length - 2] : versions[0]);
NODE
@@ -69,7 +68,7 @@ if [[ "$SKIP_PREVIOUS" == "1" ]]; then
echo "==> Skip preinstall previous (OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS=1)"
else
echo "==> Preinstall previous (forces installer upgrade path; avoids read() prompt)"
npm install -g "openclaw@${PREVIOUS_VERSION}"
quiet_npm install -g "openclaw@${PREVIOUS_VERSION}"
fi
echo "==> Run official installer one-liner"

View File

@@ -41,7 +41,7 @@ EXPECTED_VERSION="${OPENCLAW_INSTALL_EXPECT_VERSION:-}"
if [[ -n "$EXPECTED_VERSION" ]]; then
LATEST_VERSION="$EXPECTED_VERSION"
else
LATEST_VERSION="$(npm view "$PACKAGE_NAME" version)"
LATEST_VERSION="$(quiet_npm view "$PACKAGE_NAME" version)"
fi
echo "==> Verify CLI installed"
verify_installed_cli "$PACKAGE_NAME" "$LATEST_VERSION"

View File

@@ -13,14 +13,14 @@ source "$SCRIPT_DIR/../install-sh-common/cli-verify.sh"
echo "==> Resolve npm versions"
if [[ "$SKIP_PREVIOUS" == "1" ]]; then
LATEST_VERSION="$(npm view "$PACKAGE_NAME" version)"
LATEST_VERSION="$(quiet_npm view "$PACKAGE_NAME" version)"
PREVIOUS_VERSION="$LATEST_VERSION"
elif [[ -n "$SMOKE_PREVIOUS_VERSION" ]]; then
LATEST_VERSION="$(npm view "$PACKAGE_NAME" version)"
LATEST_VERSION="$(quiet_npm view "$PACKAGE_NAME" version)"
PREVIOUS_VERSION="$SMOKE_PREVIOUS_VERSION"
else
LATEST_VERSION="$(npm view "$PACKAGE_NAME" dist-tags.latest)"
VERSIONS_JSON="$(npm view "$PACKAGE_NAME" versions --json)"
LATEST_VERSION="$(quiet_npm view "$PACKAGE_NAME" dist-tags.latest)"
VERSIONS_JSON="$(quiet_npm view "$PACKAGE_NAME" versions --json)"
PREVIOUS_VERSION="$(LATEST_VERSION="$LATEST_VERSION" VERSIONS_JSON="$VERSIONS_JSON" node - <<'NODE'
const latest = String(process.env.LATEST_VERSION || "");
const raw = process.env.VERSIONS_JSON || "[]";
@@ -52,7 +52,7 @@ if [[ "$SKIP_PREVIOUS" == "1" ]]; then
echo "==> Skip preinstall previous (OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1)"
else
echo "==> Preinstall previous (forces installer upgrade path)"
npm install -g "${PACKAGE_NAME}@${PREVIOUS_VERSION}"
quiet_npm install -g "${PACKAGE_NAME}@${PREVIOUS_VERSION}"
fi
echo "==> Run official installer one-liner"

View File

@@ -1066,10 +1066,30 @@ verify_bundle_permissions() {
local npm_q cmd
npm_q="$(shell_quote "$GUEST_NPM_BIN")"
cmd="$(cat <<EOF
root=\$($npm_q root -g); check_path() { local path="\$1"; [ -e "\$path" ] || return 0; local perm perm_oct; perm=\$(/usr/bin/stat -f '%OLp' "\$path"); perm_oct=\$((8#\$perm)); if (( perm_oct & 0002 )); then echo "world-writable install artifact: \$path (\$perm)" >&2; exit 1; fi; }; check_path "\$root/openclaw"; check_path "\$root/openclaw/extensions"; if [ -d "\$root/openclaw/extensions" ]; then while IFS= read -r -d '' extension_dir; do check_path "\$extension_dir"; done < <(/usr/bin/find "\$root/openclaw/extensions" -mindepth 1 -maxdepth 1 -type d -print0); fi
set -eu
set -o pipefail
root=\$($npm_q root -g)
check_path() {
local path="\$1"
[ -e "\$path" ] || return 0
local perm perm_oct
perm=\$(/usr/bin/stat -f '%OLp' "\$path")
perm_oct=\$((8#\$perm))
if (( perm_oct & 0002 )); then
echo "world-writable install artifact: \$path (\$perm)" >&2
exit 1
fi
}
check_path "\$root/openclaw"
check_path "\$root/openclaw/extensions"
if [ -d "\$root/openclaw/extensions" ]; then
while IFS= read -r -d '' extension_dir; do
check_path "\$extension_dir"
done < <(/usr/bin/find "\$root/openclaw/extensions" -mindepth 1 -maxdepth 1 -type d -print0)
fi
EOF
)"
guest_current_user_sh "$cmd"
guest_current_user_exec /bin/bash -lc "$cmd"
}
run_ref_onboard() {

View File

@@ -4,6 +4,8 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SMOKE_IMAGE="${OPENCLAW_INSTALL_SMOKE_IMAGE:-openclaw-install-smoke:local}"
NONROOT_IMAGE="${OPENCLAW_INSTALL_NONROOT_IMAGE:-openclaw-install-nonroot:local}"
SMOKE_PLATFORM="${OPENCLAW_INSTALL_SMOKE_PLATFORM:-linux/amd64}"
NONROOT_PLATFORM="${OPENCLAW_INSTALL_NONROOT_PLATFORM:-$SMOKE_PLATFORM}"
INSTALL_URL="${OPENCLAW_INSTALL_URL:-https://openclaw.bot/install.sh}"
CLI_INSTALL_URL="${OPENCLAW_INSTALL_CLI_URL:-https://openclaw.bot/install-cli.sh}"
SKIP_NONROOT="${OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT:-0}"
@@ -17,6 +19,7 @@ if [[ "$SKIP_SMOKE_IMAGE_BUILD" == "1" ]]; then
else
echo "==> Build smoke image (upgrade, root): $SMOKE_IMAGE"
docker build \
--platform "$SMOKE_PLATFORM" \
-t "$SMOKE_IMAGE" \
-f "$ROOT_DIR/scripts/docker/install-sh-smoke/Dockerfile" \
"$ROOT_DIR/scripts/docker"
@@ -24,6 +27,7 @@ fi
echo "==> Run installer smoke test (root): $INSTALL_URL"
docker run --rm -t \
--platform "$SMOKE_PLATFORM" \
-v "${LATEST_DIR}:/out" \
-e OPENCLAW_INSTALL_URL="$INSTALL_URL" \
-e OPENCLAW_INSTALL_METHOD=npm \
@@ -48,6 +52,7 @@ else
else
echo "==> Build non-root image: $NONROOT_IMAGE"
docker build \
--platform "$NONROOT_PLATFORM" \
-t "$NONROOT_IMAGE" \
-f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \
"$ROOT_DIR/scripts/docker"
@@ -55,6 +60,7 @@ else
echo "==> Run installer non-root test: $INSTALL_URL"
docker run --rm -t \
--platform "$NONROOT_PLATFORM" \
-e OPENCLAW_INSTALL_URL="$INSTALL_URL" \
-e OPENCLAW_INSTALL_METHOD=npm \
-e OPENCLAW_INSTALL_EXPECT_VERSION="$LATEST_VERSION" \
@@ -76,6 +82,7 @@ fi
echo "==> Run CLI installer non-root test (same image)"
docker run --rm -t \
--platform "$NONROOT_PLATFORM" \
--entrypoint /bin/bash \
-e OPENCLAW_INSTALL_URL="$INSTALL_URL" \
-e OPENCLAW_INSTALL_CLI_URL="$CLI_INSTALL_URL" \

View File

@@ -311,6 +311,12 @@ export async function loadCompactHooksHarness(): Promise<{
ensureContextEnginesInitialized: vi.fn(),
resolveContextEngine: resolveContextEngineMock,
}));
vi.doMock("../../context-engine/init.js", () => ({
ensureContextEnginesInitialized: vi.fn(),
}));
vi.doMock("../../context-engine/registry.js", () => ({
resolveContextEngine: resolveContextEngineMock,
}));
vi.doMock("../../process/command-queue.js", () => ({
enqueueCommandInLane: vi.fn((_lane: unknown, task: () => unknown) => task()),

View File

@@ -36,8 +36,16 @@ const skillsLogger = createSubsystemLogger("skills");
*
* Saves ~56 tokens per skill path × N skills ≈ 400600 tokens total.
*/
function resolveUserHomeDir(): string | undefined {
try {
return path.resolve(os.homedir());
} catch {
return undefined;
}
}
function compactSkillPaths(skills: Skill[]): Skill[] {
const home = resolveHomeDir() ?? os.homedir();
const home = resolveUserHomeDir();
if (!home) return skills;
const prefix = home.endsWith(path.sep) ? home : home + path.sep;
return skills.map((s) => ({
@@ -435,11 +443,10 @@ function loadSkillEntries(
dir: managedSkillsDir,
source: "openclaw-managed",
});
const personalAgentsSkillsDir = path.resolve(
resolveHomeDir() ?? os.homedir(),
".agents",
"skills",
);
const osHomeDir = resolveUserHomeDir();
const personalAgentsSkillsDir = osHomeDir
? path.resolve(osHomeDir, ".agents", "skills")
: path.resolve(".agents", "skills");
const personalAgentsSkills = loadSkills({
dir: personalAgentsSkillsDir,
source: "agents-skills-personal",

View File

@@ -319,7 +319,6 @@ describe("handleCompactCommand", () => {
sessionId: "target-session",
}),
tokensAfter: 321,
skillsSnapshot: { prompt: "target", skills: [] },
}),
);
});

View File

@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { importFreshModule } from "../../../test/helpers/import-fresh.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { TemplateContext } from "../templating.js";
import { buildTestCtx } from "./test-ctx.js";
import { importFreshModule } from "../../../test/helpers/import-fresh.js";
const mocks = vi.hoisted(() => ({
createModelSelectionState: vi.fn(),
@@ -83,7 +83,8 @@ async function loadResolveReplyDirectivesForTest() {
resolveConfiguredDirectiveAliases: vi.fn(() => []),
}));
vi.doMock("./get-reply-directives-apply.js", () => ({
applyInlineDirectiveOverrides: (...args: unknown[]) => mocks.applyInlineDirectiveOverrides(...args),
applyInlineDirectiveOverrides: (...args: unknown[]) =>
mocks.applyInlineDirectiveOverrides(...args),
}));
vi.doMock("./get-reply-exec-overrides.js", () => ({
resolveReplyExecOverrides: (...args: unknown[]) => mocks.resolveReplyExecOverrides(...args),
@@ -193,7 +194,7 @@ describe("resolveReplyDirectives", () => {
commandAuthorized: false,
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex: new Map(),
aliasIndex: { byAlias: new Map(), byKey: new Map() },
provider: "openai",
model: "gpt-4o-mini",
hasResolvedHeartbeatModelOverride: false,

View File

@@ -199,7 +199,7 @@ function resolvePluginIdForConfiguredWebFetchProvider(
function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map<string, string> {
const map = new Map<string, string>();
for (const record of registry.plugins) {
for (const channelId of record.channels) {
for (const channelId of record.channels ?? []) {
if (channelId && !map.has(channelId)) {
map.set(channelId, record.id);
}

View File

@@ -1,10 +1,12 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { startHeartbeatRunner } from "./heartbeat-runner.js";
import { computeNextHeartbeatPhaseDueMs, resolveHeartbeatPhaseMs } from "./heartbeat-schedule.js";
import { requestHeartbeatNow, resetHeartbeatWakeStateForTests } from "./heartbeat-wake.js";
describe("startHeartbeatRunner", () => {
type RunOnce = Parameters<typeof startHeartbeatRunner>[0]["runOnce"];
const TEST_SCHEDULER_SEED = "heartbeat-runner-test-seed";
function useFakeHeartbeatTime() {
vi.useFakeTimers();
@@ -15,6 +17,7 @@ describe("startHeartbeatRunner", () => {
return startHeartbeatRunner({
cfg: heartbeatConfig(),
runOnce,
stableSchedulerSeed: TEST_SCHEDULER_SEED,
});
}
@@ -29,6 +32,18 @@ describe("startHeartbeatRunner", () => {
} as OpenClawConfig;
}
function resolveDueFromNow(nowMs: number, intervalMs: number, agentId: string) {
return computeNextHeartbeatPhaseDueMs({
nowMs,
intervalMs,
phaseMs: resolveHeartbeatPhaseMs({
schedulerSeed: TEST_SCHEDULER_SEED,
agentId,
intervalMs,
}),
});
}
function createRequestsInFlightRunSpy(skipCount: number) {
let callCount = 0;
return vi.fn().mockImplementation(async () => {
@@ -49,6 +64,7 @@ describe("startHeartbeatRunner", () => {
const runner = startHeartbeatRunner({
cfg: params.cfg,
runOnce: params.runSpy,
stableSchedulerSeed: TEST_SCHEDULER_SEED,
});
requestHeartbeatNow(params.wake);
@@ -72,8 +88,9 @@ describe("startHeartbeatRunner", () => {
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
const runner = startDefaultRunner(runSpy);
const firstDueMs = resolveDueFromNow(0, 30 * 60_000, "main");
await vi.advanceTimersByTimeAsync(30 * 60_000 + 1_000);
await vi.advanceTimersByTimeAsync(firstDueMs + 1);
expect(runSpy).toHaveBeenCalledTimes(1);
expect(runSpy.mock.calls[0]?.[0]).toEqual(
@@ -90,19 +107,26 @@ describe("startHeartbeatRunner", () => {
},
} as OpenClawConfig);
await vi.advanceTimersByTimeAsync(10 * 60_000 + 1_000);
const nowAfterReload = Date.now();
const nextMainDueMs = resolveDueFromNow(nowAfterReload, 10 * 60_000, "main");
const nextOpsDueMs = resolveDueFromNow(nowAfterReload, 15 * 60_000, "ops");
const finalDueMs = Math.max(nextMainDueMs, nextOpsDueMs);
expect(runSpy).toHaveBeenCalledTimes(2);
expect(runSpy.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({ agentId: "main", heartbeat: { every: "10m" } }),
);
await vi.advanceTimersByTimeAsync(5 * 60_000 + 1_000);
expect(runSpy).toHaveBeenCalledTimes(3);
expect(runSpy.mock.calls[2]?.[0]).toEqual(
expect.objectContaining({ agentId: "ops", heartbeat: { every: "15m" } }),
await vi.advanceTimersByTimeAsync(finalDueMs - Date.now() + 1);
expect(runSpy.mock.calls.slice(1).map((call) => call[0]?.agentId)).toEqual(
expect.arrayContaining(["main", "ops"]),
);
expect(
runSpy.mock.calls.some(
(call) => call[0]?.agentId === "main" && call[0]?.heartbeat?.every === "10m",
),
).toBe(true);
expect(
runSpy.mock.calls.some(
(call) => call[0]?.agentId === "ops" && call[0]?.heartbeat?.every === "15m",
),
).toBe(true);
runner.stop();
});
@@ -121,13 +145,14 @@ describe("startHeartbeatRunner", () => {
});
const runner = startDefaultRunner(runSpy);
const firstDueMs = resolveDueFromNow(0, 30 * 60_000, "main");
// First heartbeat fires and throws
await vi.advanceTimersByTimeAsync(30 * 60_000 + 1_000);
await vi.advanceTimersByTimeAsync(firstDueMs + 1);
expect(runSpy).toHaveBeenCalledTimes(1);
// Second heartbeat should still fire (scheduler must not be dead)
await vi.advanceTimersByTimeAsync(30 * 60_000 + 1_000);
await vi.advanceTimersByTimeAsync(30 * 60_000);
expect(runSpy).toHaveBeenCalledTimes(2);
runner.stop();
@@ -142,18 +167,27 @@ describe("startHeartbeatRunner", () => {
const cfg = {
agents: { defaults: { heartbeat: { every: "30m" } } },
} as OpenClawConfig;
const firstDueMs = resolveDueFromNow(0, 30 * 60_000, "main");
// Start runner A
const runnerA = startHeartbeatRunner({ cfg, runOnce: runSpy1 });
const runnerA = startHeartbeatRunner({
cfg,
runOnce: runSpy1,
stableSchedulerSeed: TEST_SCHEDULER_SEED,
});
// Start runner B (simulates lifecycle reload)
const runnerB = startHeartbeatRunner({ cfg, runOnce: runSpy2 });
const runnerB = startHeartbeatRunner({
cfg,
runOnce: runSpy2,
stableSchedulerSeed: TEST_SCHEDULER_SEED,
});
// Stop runner A (stale cleanup) — should NOT kill runner B's handler
runnerA.stop();
// Runner B should still fire
await vi.advanceTimersByTimeAsync(30 * 60_000 + 1_000);
await vi.advanceTimersByTimeAsync(firstDueMs + 1);
expect(runSpy2).toHaveBeenCalledTimes(1);
expect(runSpy1).not.toHaveBeenCalled();
@@ -185,10 +219,12 @@ describe("startHeartbeatRunner", () => {
const runner = startHeartbeatRunner({
cfg: heartbeatConfig(),
runOnce: runSpy,
stableSchedulerSeed: TEST_SCHEDULER_SEED,
});
const firstDueMs = resolveDueFromNow(0, 30 * 60_000, "main");
// First heartbeat returns requests-in-flight
await vi.advanceTimersByTimeAsync(30 * 60_000 + 1_000);
await vi.advanceTimersByTimeAsync(firstDueMs + 1);
expect(runSpy).toHaveBeenCalledTimes(1);
// The wake layer retries after DEFAULT_RETRY_MS (1 s). No scheduleNext()
@@ -204,15 +240,27 @@ describe("startHeartbeatRunner", () => {
// Simulate a long-running heartbeat: the first 5 calls return
// requests-in-flight (retries from the wake layer), then the 6th succeeds.
const runSpy = createRequestsInFlightRunSpy(5);
const callTimes: number[] = [];
let callCount = 0;
const runSpy = vi.fn().mockImplementation(async () => {
callTimes.push(Date.now());
callCount++;
if (callCount <= 5) {
return { status: "skipped", reason: "requests-in-flight" } as const;
}
return { status: "ran", durationMs: 1 } as const;
});
const runner = startHeartbeatRunner({
cfg: heartbeatConfig(),
runOnce: runSpy,
stableSchedulerSeed: TEST_SCHEDULER_SEED,
});
const intervalMs = 30 * 60_000;
const firstDueMs = resolveDueFromNow(0, intervalMs, "main");
// Trigger the first heartbeat at t=30m — returns requests-in-flight.
await vi.advanceTimersByTimeAsync(30 * 60_000 + 1_000);
// Trigger the first heartbeat at the agent's first slot — returns requests-in-flight.
await vi.advanceTimersByTimeAsync(firstDueMs + 1);
expect(runSpy).toHaveBeenCalledTimes(1);
// Simulate 4 more retries at short intervals (wake layer retries).
@@ -220,12 +268,12 @@ describe("startHeartbeatRunner", () => {
requestHeartbeatNow({ reason: "retry", coalesceMs: 0 });
await vi.advanceTimersByTimeAsync(1_000);
}
expect(runSpy).toHaveBeenCalledTimes(5);
expect(callTimes.some((time) => time >= firstDueMs + intervalMs)).toBe(false);
// The next interval tick at ~t=60m should still fire — the schedule
// must not have been pushed to t=30m * 6 = 180m by the 5 retries.
await vi.advanceTimersByTimeAsync(30 * 60_000);
expect(runSpy).toHaveBeenCalledTimes(6);
// The next interval tick at the next scheduled slot should still fire —
// the retries must not push the phase out by multiple intervals.
await vi.advanceTimersByTimeAsync(firstDueMs + intervalMs - Date.now() + 1);
expect(callTimes.some((time) => time >= firstDueMs + intervalMs)).toBe(true);
runner.stop();
});

View File

@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
@@ -58,6 +59,7 @@ import {
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { escapeRegExp } from "../utils.js";
import { loadOrCreateDeviceIdentity } from "./device-identity.js";
import { formatErrorMessage, hasErrnoCode } from "./errors.js";
import { isWithinActiveHours } from "./heartbeat-active-hours.js";
import {
@@ -68,6 +70,11 @@ import {
} from "./heartbeat-events-filter.js";
import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js";
import { resolveHeartbeatReasonKind } from "./heartbeat-reason.js";
import {
computeNextHeartbeatPhaseDueMs,
resolveHeartbeatPhaseMs,
resolveNextHeartbeatDueMs,
} from "./heartbeat-schedule.js";
import {
isHeartbeatEnabledForAgent,
resolveHeartbeatIntervalMs,
@@ -129,7 +136,7 @@ type HeartbeatAgentState = {
agentId: string;
heartbeat?: HeartbeatConfig;
intervalMs: number;
lastRunMs?: number;
phaseMs: number;
nextDueMs: number;
};
@@ -138,6 +145,22 @@ export type HeartbeatRunner = {
updateConfig: (cfg: OpenClawConfig) => void;
};
function resolveHeartbeatSchedulerSeed(explicitSeed?: string) {
const normalized = normalizeOptionalString(explicitSeed);
if (normalized) {
return normalized;
}
try {
return loadOrCreateDeviceIdentity().deviceId;
} catch {
return createHash("sha256")
.update(process.env.HOME ?? "")
.update("\0")
.update(process.cwd())
.digest("hex");
}
}
function hasExplicitHeartbeatAgents(cfg: OpenClawConfig) {
const list = cfg.agents?.list ?? [];
return list.some((entry) => Boolean(entry?.heartbeat));
@@ -1181,31 +1204,50 @@ export function startHeartbeatRunner(opts: {
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
runOnce?: typeof runHeartbeatOnce;
stableSchedulerSeed?: string;
}): HeartbeatRunner {
const runtime = opts.runtime ?? defaultRuntime;
const runOnce = opts.runOnce ?? runHeartbeatOnce;
const state = {
cfg: opts.cfg ?? loadConfig(),
runtime,
schedulerSeed: resolveHeartbeatSchedulerSeed(opts.stableSchedulerSeed),
agents: new Map<string, HeartbeatAgentState>(),
timer: null as NodeJS.Timeout | null,
stopped: false,
};
let initialized = false;
const resolveNextDue = (now: number, intervalMs: number, prevState?: HeartbeatAgentState) => {
if (typeof prevState?.lastRunMs === "number") {
return prevState.lastRunMs + intervalMs;
}
if (prevState && prevState.intervalMs === intervalMs && prevState.nextDueMs > now) {
return prevState.nextDueMs;
}
return now + intervalMs;
};
const resolveNextDue = (
now: number,
intervalMs: number,
phaseMs: number,
prevState?: HeartbeatAgentState,
) =>
resolveNextHeartbeatDueMs({
nowMs: now,
intervalMs,
phaseMs,
prev: prevState
? {
intervalMs: prevState.intervalMs,
phaseMs: prevState.phaseMs,
nextDueMs: prevState.nextDueMs,
}
: undefined,
});
const advanceAgentSchedule = (agent: HeartbeatAgentState, now: number) => {
agent.lastRunMs = now;
agent.nextDueMs = now + agent.intervalMs;
const advanceAgentSchedule = (agent: HeartbeatAgentState, now: number, reason?: string) => {
agent.nextDueMs =
reason === "interval"
? computeNextHeartbeatPhaseDueMs({
nowMs: now,
intervalMs: agent.intervalMs,
phaseMs: agent.phaseMs,
})
: // Targeted and action-driven wakes still count as a fresh heartbeat run
// for cooldown purposes, so keep the existing now + interval behavior.
now + agent.intervalMs;
};
const scheduleNext = () => {
@@ -1251,14 +1293,19 @@ export function startHeartbeatRunner(opts: {
if (!intervalMs) {
continue;
}
const phaseMs = resolveHeartbeatPhaseMs({
schedulerSeed: state.schedulerSeed,
agentId: agent.agentId,
intervalMs,
});
intervals.push(intervalMs);
const prevState = prevAgents.get(agent.agentId);
const nextDueMs = resolveNextDue(now, intervalMs, prevState);
const nextDueMs = resolveNextDue(now, intervalMs, phaseMs, prevState);
nextAgents.set(agent.agentId, {
agentId: agent.agentId,
heartbeat: agent.heartbeat,
intervalMs,
lastRunMs: prevState?.lastRunMs,
phaseMs,
nextDueMs,
});
}
@@ -1332,7 +1379,7 @@ export function startHeartbeatRunner(opts: {
deps: { runtime: state.runtime },
});
if (res.status !== "skipped" || res.reason !== "disabled") {
advanceAgentSchedule(targetAgent, now);
advanceAgentSchedule(targetAgent, now, reason);
}
return res.status === "ran" ? { status: "ran", durationMs: Date.now() - startedAt } : res;
} catch (err) {
@@ -1340,7 +1387,7 @@ export function startHeartbeatRunner(opts: {
log.error(`heartbeat runner: targeted runOnce threw unexpectedly: ${errMsg}`, {
error: errMsg,
});
advanceAgentSchedule(targetAgent, now);
advanceAgentSchedule(targetAgent, now, reason);
return { status: "failed", reason: errMsg };
}
}
@@ -1362,7 +1409,7 @@ export function startHeartbeatRunner(opts: {
} catch (err) {
const errMsg = formatErrorMessage(err);
log.error(`heartbeat runner: runOnce threw unexpectedly: ${errMsg}`, { error: errMsg });
advanceAgentSchedule(agent, now);
advanceAgentSchedule(agent, now, reason);
continue;
}
if (res.status === "skipped" && res.reason === "requests-in-flight") {
@@ -1374,7 +1421,7 @@ export function startHeartbeatRunner(opts: {
return res;
}
if (res.status !== "skipped" || res.reason !== "disabled") {
advanceAgentSchedule(agent, now);
advanceAgentSchedule(agent, now, reason);
}
if (res.status === "ran") {
ran = true;

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
computeNextHeartbeatPhaseDueMs,
resolveHeartbeatPhaseMs,
resolveNextHeartbeatDueMs,
} from "./heartbeat-schedule.js";
describe("heartbeat schedule helpers", () => {
it("derives a stable per-agent phase inside the interval", () => {
const first = resolveHeartbeatPhaseMs({
schedulerSeed: "device-a",
agentId: "main",
intervalMs: 60 * 60_000,
});
const second = resolveHeartbeatPhaseMs({
schedulerSeed: "device-a",
agentId: "main",
intervalMs: 60 * 60_000,
});
expect(first).toBe(second);
expect(first).toBeGreaterThanOrEqual(0);
expect(first).toBeLessThan(60 * 60_000);
});
it("returns the next future slot for the agent phase", () => {
const intervalMs = 60 * 60_000;
const phaseMs = 15 * 60_000;
expect(
computeNextHeartbeatPhaseDueMs({
nowMs: Date.parse("2026-01-01T10:10:00.000Z"),
intervalMs,
phaseMs,
}),
).toBe(Date.parse("2026-01-01T10:15:00.000Z"));
expect(
computeNextHeartbeatPhaseDueMs({
nowMs: Date.parse("2026-01-01T10:15:00.000Z"),
intervalMs,
phaseMs,
}),
).toBe(Date.parse("2026-01-01T11:15:00.000Z"));
});
it("preserves an unchanged future schedule across config reloads", () => {
const nextDueMs = Date.parse("2026-01-01T11:15:00.000Z");
expect(
resolveNextHeartbeatDueMs({
nowMs: Date.parse("2026-01-01T10:20:00.000Z"),
intervalMs: 60 * 60_000,
phaseMs: 15 * 60_000,
prev: {
intervalMs: 60 * 60_000,
phaseMs: 15 * 60_000,
nextDueMs,
},
}),
).toBe(nextDueMs);
});
});

View File

@@ -0,0 +1,59 @@
import { createHash } from "node:crypto";
function normalizeModulo(value: number, divisor: number) {
return ((value % divisor) + divisor) % divisor;
}
export function resolveHeartbeatPhaseMs(params: {
schedulerSeed: string;
agentId: string;
intervalMs: number;
}) {
const intervalMs = Math.max(1, Math.floor(params.intervalMs));
const digest = createHash("sha256").update(`${params.schedulerSeed}:${params.agentId}`).digest();
return digest.readUInt32BE(0) % intervalMs;
}
export function computeNextHeartbeatPhaseDueMs(params: {
nowMs: number;
intervalMs: number;
phaseMs: number;
}) {
const intervalMs = Math.max(1, Math.floor(params.intervalMs));
const nowMs = Math.floor(params.nowMs);
const phaseMs = normalizeModulo(Math.floor(params.phaseMs), intervalMs);
const cyclePositionMs = normalizeModulo(nowMs, intervalMs);
let deltaMs = normalizeModulo(phaseMs - cyclePositionMs, intervalMs);
if (deltaMs === 0) {
deltaMs = intervalMs;
}
return nowMs + deltaMs;
}
export function resolveNextHeartbeatDueMs(params: {
nowMs: number;
intervalMs: number;
phaseMs: number;
prev?: {
intervalMs: number;
phaseMs: number;
nextDueMs: number;
};
}) {
const intervalMs = Math.max(1, Math.floor(params.intervalMs));
const phaseMs = normalizeModulo(Math.floor(params.phaseMs), intervalMs);
const prev = params.prev;
if (
prev &&
prev.intervalMs === intervalMs &&
prev.phaseMs === phaseMs &&
prev.nextDueMs > params.nowMs
) {
return prev.nextDueMs;
}
return computeNextHeartbeatPhaseDueMs({
nowMs: params.nowMs,
intervalMs,
phaseMs,
});
}

View File

@@ -28,6 +28,6 @@ export function resolveRuntimeTextTransforms(): PluginTextTransforms | undefined
return mergePluginTextTransforms(
...(loadPluginRuntime()
?.getActivePluginRegistry()
?.textTransforms.map((entry) => entry.transforms) ?? []),
?.textTransforms?.map((entry) => entry.transforms) ?? []),
);
}