mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-19 04:42:01 +08:00
Compare commits
9 Commits
codex/fix-
...
v2026.4.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44e5b62c27 | ||
|
|
9a4a9a5993 | ||
|
|
e11d902b7d | ||
|
|
df7e61b546 | ||
|
|
5b2888e1fd | ||
|
|
421338f585 | ||
|
|
05659cfbc3 | ||
|
|
896eb888a8 | ||
|
|
05521242cd |
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
"$@"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -36,8 +36,16 @@ const skillsLogger = createSubsystemLogger("skills");
|
||||
*
|
||||
* Saves ~5–6 tokens per skill path × N skills ≈ 400–600 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",
|
||||
|
||||
@@ -319,7 +319,6 @@ describe("handleCompactCommand", () => {
|
||||
sessionId: "target-session",
|
||||
}),
|
||||
tokensAfter: 321,
|
||||
skillsSnapshot: { prompt: "target", skills: [] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
63
src/infra/heartbeat-schedule.test.ts
Normal file
63
src/infra/heartbeat-schedule.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
59
src/infra/heartbeat-schedule.ts
Normal file
59
src/infra/heartbeat-schedule.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,6 @@ export function resolveRuntimeTextTransforms(): PluginTextTransforms | undefined
|
||||
return mergePluginTextTransforms(
|
||||
...(loadPluginRuntime()
|
||||
?.getActivePluginRegistry()
|
||||
?.textTransforms.map((entry) => entry.transforms) ?? []),
|
||||
?.textTransforms?.map((entry) => entry.transforms) ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user