From 10448a0ad14cede01c6742f482e8f22672936c71 Mon Sep 17 00:00:00 2001 From: Alex Knight Date: Sun, 3 May 2026 06:17:53 +1000 Subject: [PATCH 1/4] fix(heartbeat): make phase scheduling active-hours-aware (#75487) (#75597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - The PR adds active-hours-aware heartbeat phase seeking, wires runner scheduling and config reloads through it, adds scheduler/e2e coverage, and records the user-facing fix in the changelog. - Reproducibility: yes. Current main can be reproduced with a `4h` heartbeat and an `Asia/Shanghai` active-hours window during a quiet-hours restart: main arms raw UTC-phase slots and only skips when the timer fires. ClawSweeper fixups: - Included follow-up commit: fix(heartbeat): recompute schedule when activeHours config changes vi… - Included follow-up commit: fix(heartbeat): add iteration cap to active-hours seek + edge-case tests - Included follow-up commit: chore: clean up redundant code comments - Included follow-up commit: fix(heartbeat): make phase scheduling active-hours-aware (#75487) - Included follow-up commit: fix(clawsweeper): address review for automerge-openclaw-openclaw-7559… Validation: - ClawSweeper review passed for head 02a1283c933f1f9294f54d7035e31aeb83eca736. - Required merge gates passed before the squash merge. Prepared head SHA: 02a1283c933f1f9294f54d7035e31aeb83eca736 Review: https://github.com/openclaw/openclaw/pull/75597#issuecomment-4358941180 Co-authored-by: Alex Knight Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + src/infra/heartbeat-active-hours.ts | 2 +- ...t-runner.active-hours-schedule.e2e.test.ts | 288 ++++++++++++++++++ src/infra/heartbeat-runner.ts | 65 +++- src/infra/heartbeat-schedule.test.ts | 166 ++++++++++ src/infra/heartbeat-schedule.ts | 38 +++ 6 files changed, 556 insertions(+), 4 deletions(-) create mode 100644 src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4833d4e0b..8bcd871cc89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Agents/sessions: preserve terminal lifecycle state when final run metadata persists from a stale in-memory snapshot, preventing `main` sessions from staying stuck as running after completed or timed-out turns. - Gateway/CLI: make `openclaw gateway start` repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting. +- Heartbeat/scheduler: make heartbeat phase scheduling active-hours-aware so the scheduler seeks forward to the first in-window phase slot instead of arming timers for quiet-hours slots and relying solely on the runtime guard. Non-UTC `activeHours.timezone` values (e.g. `Asia/Shanghai`) now correctly influence when the next heartbeat timer fires, avoiding wasted quiet-hours ticks and long dormant gaps after gateway restarts. Fixes #75487. Thanks @amknight. - Status: show the `openai-codex` OAuth profile for `openai/gpt-*` sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky. - Plugins/externalization: keep diagnostics ClawHub packages and persisted bundled-plugin relocation on npm-first install metadata for launch, and omit Discord from the core package now that its external package is published. Thanks @vincentkoc. - Plugins/Codex: allow the official npm Codex plugin to install without the unsafe-install override, keep `/codex` command ownership, and cover the real npm Docker live path through managed `.openclaw/npm` dependencies plus uninstall failure proof. diff --git a/src/infra/heartbeat-active-hours.ts b/src/infra/heartbeat-active-hours.ts index aedf04dd61c..9215ce30675 100644 --- a/src/infra/heartbeat-active-hours.ts +++ b/src/infra/heartbeat-active-hours.ts @@ -6,7 +6,7 @@ type HeartbeatConfig = AgentDefaultsConfig["heartbeat"]; const ACTIVE_HOURS_TIME_PATTERN = /^(?:([01]\d|2[0-3]):([0-5]\d)|24:00)$/; -function resolveActiveHoursTimezone(cfg: OpenClawConfig, raw?: string): string { +export function resolveActiveHoursTimezone(cfg: OpenClawConfig, raw?: string): string { const trimmed = raw?.trim(); if (!trimmed || trimmed === "user") { return resolveUserTimezone(cfg.agents?.defaults?.userTimezone); diff --git a/src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts b/src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts new file mode 100644 index 00000000000..d69179f6980 --- /dev/null +++ b/src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts @@ -0,0 +1,288 @@ +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 { resetHeartbeatWakeStateForTests } from "./heartbeat-wake.js"; + +/** Verifies that the scheduler seeks to in-window phase slots (#75487). */ +describe("heartbeat scheduler: activeHours-aware scheduling (#75487)", () => { + type RunOnce = Parameters[0]["runOnce"]; + const TEST_SCHEDULER_SEED = "heartbeat-ah-schedule-test-seed"; + + function useFakeHeartbeatTime(startMs: number) { + vi.useFakeTimers(); + vi.setSystemTime(new Date(startMs)); + } + + function heartbeatConfig(overrides?: { + every?: string; + activeHours?: { start: string; end: string; timezone?: string }; + userTimezone?: string; + }): OpenClawConfig { + return { + agents: { + defaults: { + heartbeat: { + every: overrides?.every ?? "4h", + ...(overrides?.activeHours ? { activeHours: overrides.activeHours } : {}), + }, + ...(overrides?.userTimezone ? { userTimezone: overrides.userTimezone } : {}), + }, + }, + }; + } + + function resolveDueFromNow(nowMs: number, intervalMs: number, agentId: string) { + return computeNextHeartbeatPhaseDueMs({ + nowMs, + intervalMs, + phaseMs: resolveHeartbeatPhaseMs({ + schedulerSeed: TEST_SCHEDULER_SEED, + agentId, + intervalMs, + }), + }); + } + + afterEach(() => { + resetHeartbeatWakeStateForTests(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("skips quiet-hours slots and fires at the first in-window phase slot", async () => { + // 09:00–17:00 UTC, 4h interval. Start at 16:30 — raw due is after 17:00. + const startMs = Date.parse("2026-06-15T16:30:00.000Z"); + useFakeHeartbeatTime(startMs); + + const intervalMs = 4 * 60 * 60_000; + const callTimes: number[] = []; + const runSpy: RunOnce = vi.fn().mockImplementation(async () => { + callTimes.push(Date.now()); + return { status: "ran", durationMs: 1 }; + }); + + const runner = startHeartbeatRunner({ + cfg: heartbeatConfig({ + every: "4h", + activeHours: { start: "09:00", end: "17:00", timezone: "UTC" }, + }), + runOnce: runSpy, + stableSchedulerSeed: TEST_SCHEDULER_SEED, + }); + + const rawDueMs = resolveDueFromNow(startMs, intervalMs, "main"); + + // Advance past the raw due — should NOT fire (quiet hours). + await vi.advanceTimersByTimeAsync(rawDueMs - startMs + 1); + + // Advance to end of next day's window — should fire within 09:00–17:00. + const safeEndOfWindow = Date.parse("2026-06-16T17:00:00.000Z"); + await vi.advanceTimersByTimeAsync(safeEndOfWindow - Date.now()); + + expect(runSpy).toHaveBeenCalled(); + const firstCallHourUTC = new Date(callTimes[0]).getUTCHours(); + expect(firstCallHourUTC).toBeGreaterThanOrEqual(9); + expect(firstCallHourUTC).toBeLessThan(17); + + runner.stop(); + }); + + it("fires immediately when the first phase slot is already within active hours", async () => { + const startMs = Date.parse("2026-06-15T10:00:00.000Z"); + useFakeHeartbeatTime(startMs); + + const intervalMs = 4 * 60 * 60_000; + const runSpy: RunOnce = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }); + + const runner = startHeartbeatRunner({ + cfg: heartbeatConfig({ + every: "4h", + activeHours: { start: "08:00", end: "20:00", timezone: "UTC" }, + }), + runOnce: runSpy, + stableSchedulerSeed: TEST_SCHEDULER_SEED, + }); + + const rawDueMs = resolveDueFromNow(startMs, intervalMs, "main"); + await vi.advanceTimersByTimeAsync(rawDueMs - startMs + 1); + + expect(runSpy).toHaveBeenCalledTimes(1); + runner.stop(); + }); + + it("seeks forward correctly with a non-UTC timezone (e.g. America/New_York)", async () => { + // 09:00–17:00 ET (EDT = UTC-4 in June) → 13:00–21:00 UTC. + // Start at 21:30 UTC (17:30 ET = outside window). + const startMs = Date.parse("2026-06-15T21:30:00.000Z"); + useFakeHeartbeatTime(startMs); + + const callTimes: number[] = []; + const runSpy: RunOnce = vi.fn().mockImplementation(async () => { + callTimes.push(Date.now()); + return { status: "ran", durationMs: 1 }; + }); + + const runner = startHeartbeatRunner({ + cfg: heartbeatConfig({ + every: "4h", + activeHours: { start: "09:00", end: "17:00", timezone: "America/New_York" }, + }), + runOnce: runSpy, + stableSchedulerSeed: TEST_SCHEDULER_SEED, + }); + + await vi.advanceTimersByTimeAsync(48 * 60 * 60_000); + + expect(runSpy).toHaveBeenCalled(); + const firstCallHourUTC = new Date(callTimes[0]).getUTCHours(); + expect(firstCallHourUTC).toBeGreaterThanOrEqual(13); + expect(firstCallHourUTC).toBeLessThan(21); + + runner.stop(); + }); + + it("advances to in-window slot after a quiet-hours skip during interval runs", async () => { + // 09:00–17:00 UTC, 4h interval. Verify ALL fires over 48h stay in-window. + const startMs = Date.parse("2026-06-15T09:00:00.000Z"); + useFakeHeartbeatTime(startMs); + + const callTimes: number[] = []; + const runSpy: RunOnce = vi.fn().mockImplementation(async () => { + callTimes.push(Date.now()); + return { status: "ran", durationMs: 1 }; + }); + + const runner = startHeartbeatRunner({ + cfg: heartbeatConfig({ + every: "4h", + activeHours: { start: "09:00", end: "17:00", timezone: "UTC" }, + }), + runOnce: runSpy, + stableSchedulerSeed: TEST_SCHEDULER_SEED, + }); + + await vi.advanceTimersByTimeAsync(48 * 60 * 60_000); + + expect(callTimes.length).toBeGreaterThan(0); + for (const t of callTimes) { + const hour = new Date(t).getUTCHours(); + expect( + hour, + `fire at ${new Date(t).toISOString()} is outside active window`, + ).toBeGreaterThanOrEqual(9); + expect(hour, `fire at ${new Date(t).toISOString()} is outside active window`).toBeLessThan( + 17, + ); + } + + runner.stop(); + }); + + it("does not loop indefinitely when activeHours window is zero-width", async () => { + // start === end → never-active; seek falls back, runtime guard skips. + const startMs = Date.parse("2026-06-15T10:00:00.000Z"); + useFakeHeartbeatTime(startMs); + + const runSpy: RunOnce = vi.fn().mockResolvedValue({ status: "skipped", reason: "quiet-hours" }); + + const runner = startHeartbeatRunner({ + cfg: heartbeatConfig({ + every: "30m", + activeHours: { start: "12:00", end: "12:00", timezone: "UTC" }, + }), + runOnce: runSpy, + stableSchedulerSeed: TEST_SCHEDULER_SEED, + }); + + await vi.advanceTimersByTimeAsync(2 * 60 * 60_000); + + expect(runSpy).toHaveBeenCalled(); + runner.stop(); + }); + + it("recomputes schedule when activeHours config changes via hot reload", async () => { + // Narrow window pushes nextDueMs to tomorrow; widening via updateConfig + // must recompute from `now` so the timer fires today. + const startMs = Date.parse("2026-06-15T14:00:00.000Z"); + useFakeHeartbeatTime(startMs); + + const callTimes: number[] = []; + const runSpy: RunOnce = vi.fn().mockImplementation(async () => { + callTimes.push(Date.now()); + return { status: "ran", durationMs: 1 }; + }); + + const runner = startHeartbeatRunner({ + cfg: heartbeatConfig({ + every: "4h", + activeHours: { start: "09:00", end: "10:00", timezone: "UTC" }, + }), + runOnce: runSpy, + stableSchedulerSeed: TEST_SCHEDULER_SEED, + }); + + await vi.advanceTimersByTimeAsync(60 * 60_000); + expect(runSpy).not.toHaveBeenCalled(); + + // Widen window — scheduler must recompute, not keep stale tomorrow slot. + runner.updateConfig( + heartbeatConfig({ + every: "4h", + activeHours: { start: "08:00", end: "20:00", timezone: "UTC" }, + }), + ); + + await vi.advanceTimersByTimeAsync(8 * 60 * 60_000); + expect(runSpy).toHaveBeenCalled(); + const firstCallHour = new Date(callTimes[0]).getUTCHours(); + expect(firstCallHour).toBeGreaterThanOrEqual(8); + expect(firstCallHour).toBeLessThan(20); + expect(new Date(callTimes[0]).getUTCDate()).toBe(15); // today, not tomorrow + + runner.stop(); + }); + + it("recomputes schedule when activeHours effective timezone changes via hot reload", async () => { + const startMs = Date.parse("2026-06-15T14:00:00.000Z"); + useFakeHeartbeatTime(startMs); + + const callTimes: number[] = []; + const runSpy: RunOnce = vi.fn().mockImplementation(async () => { + callTimes.push(Date.now()); + return { status: "ran", durationMs: 1 }; + }); + + const activeHours = { start: "16:00", end: "17:00" }; + const runner = startHeartbeatRunner({ + cfg: heartbeatConfig({ + every: "4h", + activeHours, + userTimezone: "America/New_York", + }), + runOnce: runSpy, + stableSchedulerSeed: TEST_SCHEDULER_SEED, + }); + + await vi.advanceTimersByTimeAsync(60 * 60_000); + expect(runSpy).not.toHaveBeenCalled(); + + runner.updateConfig( + heartbeatConfig({ + every: "4h", + activeHours, + userTimezone: "UTC", + }), + ); + + const endOfUtcWindow = Date.parse("2026-06-15T17:00:00.000Z"); + await vi.advanceTimersByTimeAsync(endOfUtcWindow - Date.now()); + + expect(runSpy).toHaveBeenCalled(); + const firstCall = new Date(callTimes[0]); + expect(firstCall.getUTCHours()).toBe(16); + expect(firstCall.getUTCDate()).toBe(15); + + runner.stop(); + }); +}); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index b23d704f194..9699cc9de9e 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -83,7 +83,7 @@ import { escapeRegExp } from "../utils.js"; import { MAX_SAFE_TIMEOUT_DELAY_MS, resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js"; import { loadOrCreateDeviceIdentity } from "./device-identity.js"; import { formatErrorMessage, hasErrnoCode } from "./errors.js"; -import { isWithinActiveHours } from "./heartbeat-active-hours.js"; +import { isWithinActiveHours, resolveActiveHoursTimezone } from "./heartbeat-active-hours.js"; import { recordRunStart, shouldDeferWake, type DeferDecision } from "./heartbeat-cooldown.js"; import { buildCronEventPrompt, @@ -97,6 +97,7 @@ import { computeNextHeartbeatPhaseDueMs, resolveHeartbeatPhaseMs, resolveNextHeartbeatDueMs, + seekNextActivePhaseDueMs, } from "./heartbeat-schedule.js"; import { isHeartbeatEnabledForAgent, @@ -212,6 +213,7 @@ function canHeartbeatDeliverCommitments(heartbeat?: HeartbeatConfig): boolean { type HeartbeatAgentState = { agentId: string; heartbeat?: HeartbeatConfig; + activeHoursSchedule?: ActiveHoursSchedule; intervalMs: number; phaseMs: number; nextDueMs: number; @@ -223,6 +225,37 @@ type HeartbeatAgentState = { floodLoggedSinceLastRun: boolean; }; +type ActiveHoursSchedule = { + start?: string; + end?: string; + timezone: string; +}; + +function resolveActiveHoursSchedule( + cfg: OpenClawConfig, + heartbeat?: HeartbeatConfig, +): ActiveHoursSchedule | undefined { + const activeHours = heartbeat?.activeHours; + if (!activeHours) { + return undefined; + } + return { + start: activeHours.start, + end: activeHours.end, + timezone: resolveActiveHoursTimezone(cfg, activeHours.timezone), + }; +} + +function activeHoursConfigMatch(a?: ActiveHoursSchedule, b?: ActiveHoursSchedule): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + return a.start === b.start && a.end === b.end && a.timezone === b.timezone; +} + export type HeartbeatRunner = { stop: () => void; updateConfig: (cfg: OpenClawConfig) => void; @@ -1779,8 +1812,16 @@ export function startHeartbeatRunner(opts: { : undefined, }); + const seekActiveSlotForAgent = (agent: HeartbeatAgentState, rawDueMs: number) => + seekNextActivePhaseDueMs({ + startMs: rawDueMs, + intervalMs: agent.intervalMs, + phaseMs: agent.phaseMs, + isActive: (ms) => isWithinActiveHours(state.cfg, agent.heartbeat, ms), + }); + const advanceAgentSchedule = (agent: HeartbeatAgentState, now: number, reason?: string) => { - agent.nextDueMs = + const rawDueMs = reason === "interval" ? computeNextHeartbeatPhaseDueMs({ nowMs: now, @@ -1790,6 +1831,7 @@ export function startHeartbeatRunner(opts: { : // 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; + agent.nextDueMs = seekActiveSlotForAgent(agent, rawDueMs); }; // Centralized cooldown gate. Both targeted and broadcast dispatch branches @@ -1894,10 +1936,27 @@ export function startHeartbeatRunner(opts: { }); intervals.push(intervalMs); const prevState = prevAgents.get(agent.agentId); - const nextDueMs = resolveNextDue(now, intervalMs, phaseMs, prevState); + const activeHoursSchedule = resolveActiveHoursSchedule(cfg, agent.heartbeat); + // resolveNextDue only compares intervalMs/phaseMs, so discard + // prevState when the effective activeHours window changed to avoid a stale far-future slot. + const ahChanged = + prevState && !activeHoursConfigMatch(prevState.activeHoursSchedule, activeHoursSchedule); + const rawNextDueMs = resolveNextDue( + now, + intervalMs, + phaseMs, + ahChanged ? undefined : prevState, + ); + const nextDueMs = seekNextActivePhaseDueMs({ + startMs: rawNextDueMs, + intervalMs, + phaseMs, + isActive: (ms) => isWithinActiveHours(cfg, agent.heartbeat, ms), + }); nextAgents.set(agent.agentId, { agentId: agent.agentId, heartbeat: agent.heartbeat, + activeHoursSchedule, intervalMs, phaseMs, nextDueMs, diff --git a/src/infra/heartbeat-schedule.test.ts b/src/infra/heartbeat-schedule.test.ts index c4d848f0fdf..9f01e459c25 100644 --- a/src/infra/heartbeat-schedule.test.ts +++ b/src/infra/heartbeat-schedule.test.ts @@ -3,6 +3,7 @@ import { computeNextHeartbeatPhaseDueMs, resolveHeartbeatPhaseMs, resolveNextHeartbeatDueMs, + seekNextActivePhaseDueMs, } from "./heartbeat-schedule.js"; describe("heartbeat schedule helpers", () => { @@ -61,3 +62,168 @@ describe("heartbeat schedule helpers", () => { ).toBe(nextDueMs); }); }); + +describe("seekNextActivePhaseDueMs", () => { + const HOUR = 60 * 60_000; + + it("returns startMs immediately when no isActive predicate is provided", () => { + const startMs = Date.parse("2026-01-01T03:00:00.000Z"); + expect( + seekNextActivePhaseDueMs({ + startMs, + intervalMs: 4 * HOUR, + phaseMs: 0, + }), + ).toBe(startMs); + }); + + it("returns startMs when the first slot is already within active hours", () => { + const startMs = Date.parse("2026-01-01T10:00:00.000Z"); + expect( + seekNextActivePhaseDueMs({ + startMs, + intervalMs: 4 * HOUR, + phaseMs: 0, + isActive: () => true, + }), + ).toBe(startMs); + }); + + it("skips quiet-hours slots and returns the first in-window slot", () => { + // 08:00–17:00 UTC, 4h interval, start at 19:00 (quiet). + const startMs = Date.parse("2026-01-01T19:00:00.000Z"); + const intervalMs = 4 * HOUR; + const isActive = (ms: number) => { + const hour = new Date(ms).getUTCHours(); + return hour >= 8 && hour < 17; + }; + + const result = seekNextActivePhaseDueMs({ + startMs, + intervalMs, + phaseMs: 0, + isActive, + }); + + expect(result).toBe(Date.parse("2026-01-02T11:00:00.000Z")); + }); + + it("handles overnight active windows correctly", () => { + // 22:00–06:00 UTC (overnight), 4h interval, start at 10:00 (quiet). + const startMs = Date.parse("2026-01-01T10:00:00.000Z"); + const intervalMs = 4 * HOUR; + const isActive = (ms: number) => { + const hour = new Date(ms).getUTCHours(); + return hour >= 22 || hour < 6; + }; + + const result = seekNextActivePhaseDueMs({ + startMs, + intervalMs, + phaseMs: 0, + isActive, + }); + + expect(result).toBe(Date.parse("2026-01-01T22:00:00.000Z")); + }); + + it("falls back to startMs when no slot is active within the seek horizon", () => { + const startMs = Date.parse("2026-01-01T10:00:00.000Z"); + const result = seekNextActivePhaseDueMs({ + startMs, + intervalMs: 4 * HOUR, + phaseMs: 0, + isActive: () => false, + }); + + expect(result).toBe(startMs); + }); + + it("seeks across timezone-aware active hours using isWithinActiveHours semantics", () => { + // Asia/Shanghai (UTC+8): active 08:00–23:00 local. + const startMs = Date.parse("2026-01-01T15:21:00.000Z"); + const intervalMs = 4 * HOUR; + const shanghaiOffsetMs = 8 * HOUR; + + const isActive = (ms: number) => { + const shanghaiMs = ms + shanghaiOffsetMs; + const shanghaiHour = new Date(shanghaiMs).getUTCHours(); + return shanghaiHour >= 8 && shanghaiHour < 23; + }; + + const result = seekNextActivePhaseDueMs({ + startMs, + intervalMs, + phaseMs: 0, + isActive, + }); + + expect(result).toBe(Date.parse("2026-01-02T03:21:00.000Z")); + }); + + it("handles very short intervals efficiently", () => { + // 30m interval, 09:00–17:00. Start at 17:00 (quiet) → 09:00 next day. + const startMs = Date.parse("2026-01-01T17:00:00.000Z"); + const intervalMs = 30 * 60_000; + const isActive = (ms: number) => { + const hour = new Date(ms).getUTCHours(); + return hour >= 9 && hour < 17; + }; + + const result = seekNextActivePhaseDueMs({ + startMs, + intervalMs, + phaseMs: 0, + isActive, + }); + + expect(result).toBe(Date.parse("2026-01-02T09:00:00.000Z")); + }); + + it("caps iterations for pathological sub-second intervals", () => { + const startMs = Date.parse("2026-01-01T12:00:00.000Z"); + const t0 = performance.now(); + const result = seekNextActivePhaseDueMs({ + startMs, + intervalMs: 1, // 1ms — pathological + phaseMs: 0, + isActive: () => false, + }); + const elapsedMs = performance.now() - t0; + + expect(result).toBe(startMs); + expect(elapsedMs).toBeLessThan(500); + }); + + it("handles intervalMs larger than the seek horizon", () => { + const startMs = Date.parse("2026-01-01T03:00:00.000Z"); + const eightDays = 8 * 24 * HOUR; + const result = seekNextActivePhaseDueMs({ + startMs, + intervalMs: eightDays, + phaseMs: 0, + isActive: (ms) => { + const hour = new Date(ms).getUTCHours(); + return hour >= 9 && hour < 17; + }, + }); + + expect(result).toBe(startMs); + }); + + it("returns startMs when intervalMs larger than horizon and startMs is active", () => { + const startMs = Date.parse("2026-01-01T12:00:00.000Z"); // 12:00 — active + const eightDays = 8 * 24 * HOUR; + const result = seekNextActivePhaseDueMs({ + startMs, + intervalMs: eightDays, + phaseMs: 0, + isActive: (ms) => { + const hour = new Date(ms).getUTCHours(); + return hour >= 9 && hour < 17; + }, + }); + + expect(result).toBe(startMs); + }); +}); diff --git a/src/infra/heartbeat-schedule.ts b/src/infra/heartbeat-schedule.ts index 65b2fc4cd0e..6df73e1e0f0 100644 --- a/src/infra/heartbeat-schedule.ts +++ b/src/infra/heartbeat-schedule.ts @@ -57,3 +57,41 @@ export function resolveNextHeartbeatDueMs(params: { phaseMs, }); } + +/** + * Seek forward through phase-aligned slots until one falls within the active + * hours window. Falls back to the raw next slot when no predicate is provided + * or no in-window slot is found within the seek horizon. + * + * The caller binds config/heartbeat into `isActive` so this module stays + * config-agnostic. `phaseMs` is unused — alignment is preserved because + * `startMs` is already phase-aligned and `intervalMs` addition maintains it. + */ +const MAX_SEEK_HORIZON_MS = 7 * 24 * 60 * 60_000; +// Prevent pathological sub-minute intervals from blocking the event loop. +const MAX_SEEK_ITERATIONS = 10_080; // 7 days at 1-minute steps + +export function seekNextActivePhaseDueMs(params: { + startMs: number; + intervalMs: number; + phaseMs: number; + isActive?: (ms: number) => boolean; +}): number { + const isActive = params.isActive; + if (!isActive) { + return params.startMs; + } + const intervalMs = Math.max(1, Math.floor(params.intervalMs)); + const horizonMs = params.startMs + MAX_SEEK_HORIZON_MS; + let candidateMs = params.startMs; + let iterations = 0; + while (candidateMs <= horizonMs && iterations < MAX_SEEK_ITERATIONS) { + if (isActive(candidateMs)) { + return candidateMs; + } + candidateMs += intervalMs; + iterations++; + } + // No in-window slot found; fall back so the runtime guard can gate it. + return params.startMs; +} From b779c45a78147337fda65dc400519ded7190c8b1 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Sat, 2 May 2026 15:25:00 -0500 Subject: [PATCH 2/4] fix: memoize plugin descriptor config keys (#76240) --- CHANGELOG.md | 1 + src/plugins/tool-descriptor-cache.test.ts | 93 +++++++++++++++++++++++ src/plugins/tool-descriptor-cache.ts | 34 +++++++-- src/plugins/tools.ts | 9 +++ 4 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 src/plugins/tool-descriptor-cache.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bcd871cc89..1af27bdd6dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Gateway/CLI: make `openclaw gateway start` repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting. - Heartbeat/scheduler: make heartbeat phase scheduling active-hours-aware so the scheduler seeks forward to the first in-window phase slot instead of arming timers for quiet-hours slots and relying solely on the runtime guard. Non-UTC `activeHours.timezone` values (e.g. `Asia/Shanghai`) now correctly influence when the next heartbeat timer fires, avoiding wasted quiet-hours ticks and long dormant gaps after gateway restarts. Fixes #75487. Thanks @amknight. - Status: show the `openai-codex` OAuth profile for `openai/gpt-*` sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky. +- Gateway: avoid repeated plugin tool descriptor config hashing so large runtime configs do not block reply startup and trigger reconnect/timeouts. (#75944) Thanks @joshavant. - Plugins/externalization: keep diagnostics ClawHub packages and persisted bundled-plugin relocation on npm-first install metadata for launch, and omit Discord from the core package now that its external package is published. Thanks @vincentkoc. - Plugins/Codex: allow the official npm Codex plugin to install without the unsafe-install override, keep `/codex` command ownership, and cover the real npm Docker live path through managed `.openclaw/npm` dependencies plus uninstall failure proof. - Gateway/status: add concrete service, config, listener-owner, and log collection next steps when gateway probes fail and Bonjour finds no local gateway, so frozen or port-conflict reports include the data needed for root-cause triage. Refs #49012. Thanks @vincentkoc. diff --git a/src/plugins/tool-descriptor-cache.test.ts b/src/plugins/tool-descriptor-cache.test.ts new file mode 100644 index 00000000000..f9e5128a405 --- /dev/null +++ b/src/plugins/tool-descriptor-cache.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => ({ + resolveRuntimeConfigCacheKey: vi.fn((value: unknown) => { + const id = + value && typeof value === "object" && "id" in value + ? String((value as { id?: unknown }).id) + : "config"; + return `config:${id}`; + }), +})); + +vi.mock("../config/runtime-snapshot.js", () => ({ + resolveRuntimeConfigCacheKey: hoisted.resolveRuntimeConfigCacheKey, +})); + +import { + buildPluginToolDescriptorCacheKey, + createPluginToolDescriptorConfigCacheKeyMemo, + resetPluginToolDescriptorCache, +} from "./tool-descriptor-cache.js"; + +describe("plugin tool descriptor cache keys", () => { + afterEach(() => { + hoisted.resolveRuntimeConfigCacheKey.mockClear(); + resetPluginToolDescriptorCache(); + }); + + it("memoizes config cache keys across plugin descriptor keys in one resolution pass", () => { + const config = { + id: "runtime", + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + } as never; + const configCacheKeyMemo = createPluginToolDescriptorConfigCacheKeyMemo(); + + for (let index = 0; index < 25; index += 1) { + buildPluginToolDescriptorCacheKey({ + pluginId: `plugin-${index}`, + source: `/tmp/plugin-${index}.js`, + contractToolNames: [`tool_${index}`], + ctx: { + config, + runtimeConfig: config, + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + agentId: "main", + sessionKey: "agent:main", + sessionId: "session", + }, + currentRuntimeConfig: config, + configCacheKeyMemo, + }); + } + + expect(hoisted.resolveRuntimeConfigCacheKey).toHaveBeenCalledTimes(1); + }); + + it("keeps distinct config objects distinct within the memo", () => { + const firstConfig = { id: "first" } as never; + const secondConfig = { id: "second" } as never; + const configCacheKeyMemo = createPluginToolDescriptorConfigCacheKeyMemo(); + + const firstKey = buildPluginToolDescriptorCacheKey({ + pluginId: "demo", + source: "/tmp/demo.js", + contractToolNames: ["demo"], + ctx: { + config: firstConfig, + runtimeConfig: firstConfig, + }, + currentRuntimeConfig: firstConfig, + configCacheKeyMemo, + }); + const secondKey = buildPluginToolDescriptorCacheKey({ + pluginId: "demo", + source: "/tmp/demo.js", + contractToolNames: ["demo"], + ctx: { + config: secondConfig, + runtimeConfig: secondConfig, + }, + currentRuntimeConfig: secondConfig, + configCacheKeyMemo, + }); + + expect(hoisted.resolveRuntimeConfigCacheKey).toHaveBeenCalledTimes(2); + expect(firstKey).not.toBe(secondKey); + }); +}); diff --git a/src/plugins/tool-descriptor-cache.ts b/src/plugins/tool-descriptor-cache.ts index 9ea47b6520f..a219d5e56ec 100644 --- a/src/plugins/tool-descriptor-cache.ts +++ b/src/plugins/tool-descriptor-cache.ts @@ -19,6 +19,12 @@ const descriptorCache = new Map(); let descriptorCacheObjectIds = new WeakMap(); let nextDescriptorCacheObjectId = 1; +export type PluginToolDescriptorConfigCacheKeyMemo = WeakMap; + +export function createPluginToolDescriptorConfigCacheKeyMemo(): PluginToolDescriptorConfigCacheKeyMemo { + return new WeakMap(); +} + export function resetPluginToolDescriptorCache(): void { descriptorCache.clear(); descriptorCacheObjectIds = new WeakMap(); @@ -49,26 +55,38 @@ function getDescriptorCacheObjectId(value: object | null | undefined): number | function getDescriptorConfigCacheKey( value: PluginLoadOptions["config"] | null | undefined, + memo?: PluginToolDescriptorConfigCacheKeyMemo, ): string | number | null { if (!value) { return null; } - try { - return resolveRuntimeConfigCacheKey(value); - } catch { - return getDescriptorCacheObjectId(value); + const cached = memo?.get(value); + if (cached !== undefined) { + return cached; } + let resolved: string | number | null; + try { + resolved = resolveRuntimeConfigCacheKey(value); + } catch { + resolved = getDescriptorCacheObjectId(value); + } + memo?.set(value, resolved); + return resolved; } function buildDescriptorContextCacheKey(params: { ctx: OpenClawPluginToolContext; currentRuntimeConfig?: PluginLoadOptions["config"] | null; + configCacheKeyMemo?: PluginToolDescriptorConfigCacheKeyMemo; }): string { const { ctx } = params; return JSON.stringify({ - config: getDescriptorConfigCacheKey(ctx.config), - runtimeConfig: getDescriptorConfigCacheKey(ctx.runtimeConfig), - currentRuntimeConfig: getDescriptorConfigCacheKey(params.currentRuntimeConfig), + config: getDescriptorConfigCacheKey(ctx.config, params.configCacheKeyMemo), + runtimeConfig: getDescriptorConfigCacheKey(ctx.runtimeConfig, params.configCacheKeyMemo), + currentRuntimeConfig: getDescriptorConfigCacheKey( + params.currentRuntimeConfig, + params.configCacheKeyMemo, + ), fsPolicy: ctx.fsPolicy ?? null, workspaceDir: ctx.workspaceDir ?? null, agentDir: ctx.agentDir ?? null, @@ -92,6 +110,7 @@ export function buildPluginToolDescriptorCacheKey(params: { contractToolNames: readonly string[]; ctx: OpenClawPluginToolContext; currentRuntimeConfig?: PluginLoadOptions["config"] | null; + configCacheKeyMemo?: PluginToolDescriptorConfigCacheKeyMemo; }): string { return JSON.stringify({ version: PLUGIN_TOOL_DESCRIPTOR_CACHE_VERSION, @@ -103,6 +122,7 @@ export function buildPluginToolDescriptorCacheKey(params: { context: buildDescriptorContextCacheKey({ ctx: params.ctx, currentRuntimeConfig: params.currentRuntimeConfig, + configCacheKeyMemo: params.configCacheKeyMemo, }), }); } diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index bcad128496c..a04c33f3b6a 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -21,8 +21,10 @@ import { findUndeclaredPluginToolNames } from "./tool-contracts.js"; import { buildPluginToolDescriptorCacheKey, capturePluginToolDescriptor, + createPluginToolDescriptorConfigCacheKeyMemo, readCachedPluginToolDescriptors, type CachedPluginToolDescriptor, + type PluginToolDescriptorConfigCacheKeyMemo, writeCachedPluginToolDescriptors, } from "./tool-descriptor-cache.js"; import type { OpenClawPluginToolContext } from "./types.js"; @@ -395,6 +397,7 @@ function buildPluginDescriptorCacheKey(params: { plugin: PluginManifestRecord; ctx: OpenClawPluginToolContext; currentRuntimeConfig?: PluginLoadOptions["config"] | null; + configCacheKeyMemo?: PluginToolDescriptorConfigCacheKeyMemo; }): string { return buildPluginToolDescriptorCacheKey({ pluginId: params.plugin.id, @@ -403,6 +406,7 @@ function buildPluginDescriptorCacheKey(params: { contractToolNames: params.plugin.contracts?.tools ?? [], ctx: params.ctx, currentRuntimeConfig: params.currentRuntimeConfig, + configCacheKeyMemo: params.configCacheKeyMemo, }); } @@ -493,6 +497,7 @@ function resolveCachedPluginTools(params: { loadContext: ReturnType; runtimeOptions: PluginLoadOptions["runtimeOptions"]; currentRuntimeConfig?: PluginLoadOptions["config"] | null; + configCacheKeyMemo: PluginToolDescriptorConfigCacheKeyMemo; }): { tools: AnyAgentTool[]; handledPluginIds: Set } { const tools: AnyAgentTool[] = []; const handledPluginIds = new Set(); @@ -535,6 +540,7 @@ function resolveCachedPluginTools(params: { plugin, ctx: params.ctx, currentRuntimeConfig: params.currentRuntimeConfig, + configCacheKeyMemo: params.configCacheKeyMemo, }), ); if ( @@ -714,6 +720,7 @@ export function resolvePluginTools(params: { const existing = params.existingToolNames ?? new Set(); const existingNormalized = new Set(Array.from(existing, (tool) => normalizeToolName(tool))); const allowlist = normalizeAllowlist(params.toolAllowlist); + const configCacheKeyMemo = createPluginToolDescriptorConfigCacheKeyMemo(); let currentRuntimeConfigForDescriptorCache: PluginLoadOptions["config"] | null | undefined = params.context.runtimeConfig; if (currentRuntimeConfigForDescriptorCache === undefined && params.context.getRuntimeConfig) { @@ -737,6 +744,7 @@ export function resolvePluginTools(params: { loadContext: context, runtimeOptions, currentRuntimeConfig: currentRuntimeConfigForDescriptorCache, + configCacheKeyMemo, }); tools.push(...cached.tools); const runtimePluginIds = onlyPluginIds.filter( @@ -922,6 +930,7 @@ export function resolvePluginTools(params: { plugin: manifestPlugin, ctx: params.context, currentRuntimeConfig: currentRuntimeConfigForDescriptorCache, + configCacheKeyMemo, }), descriptors, }); From 292bbcc2925321d0e27e683dc515d68a964d91c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 21:27:44 +0100 Subject: [PATCH 3/4] fix(cli): reject codex simple-completion probes --- CHANGELOG.md | 1 + src/cli/capability-cli.test.ts | 42 ++++++++++++++++++++++++++++++++++ src/cli/capability-cli.ts | 5 ++++ 3 files changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1af27bdd6dd..36316d6c9b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/infer: reject local `codex/*` one-shot model probes before simple-completion dispatch and point operators at the Codex app-server runtime path instead of ending with an empty-output error. - Agents/sessions: preserve terminal lifecycle state when final run metadata persists from a stale in-memory snapshot, preventing `main` sessions from staying stuck as running after completed or timed-out turns. - Gateway/CLI: make `openclaw gateway start` repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting. - Heartbeat/scheduler: make heartbeat phase scheduling active-hours-aware so the scheduler seeks forward to the first in-window phase slot instead of arming timers for quiet-hours slots and relying solely on the runtime guard. Non-UTC `activeHours.timezone` values (e.g. `Asia/Shanghai`) now correctly influence when the next heartbeat timer fires, avoiding wasted quiet-hours ticks and long dormant gaps after gateway restarts. Fixes #75487. Thanks @amknight. diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index ae6b6ad459d..6de317155fb 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -547,6 +547,48 @@ describe("capability cli", () => { expect(mocks.runtime.writeJson).not.toHaveBeenCalled(); }); + it("rejects local Codex provider probes before simple-completion dispatch", async () => { + mocks.prepareSimpleCompletionModelForAgent.mockResolvedValueOnce({ + selection: { + provider: "codex", + modelId: "gpt-5.4", + agentDir: "/tmp/agent", + }, + model: { + provider: "codex", + id: "gpt-5.4", + api: "openai-codex-responses", + }, + auth: { + apiKey: "codex-app-server", + source: "codex-app-server", + mode: "token", + }, + } as never); + + await expect( + runRegisteredCli({ + register: registerCapabilityCli as (program: Command) => void, + argv: [ + "capability", + "model", + "run", + "--model", + "codex/gpt-5.4", + "--prompt", + "hello", + "--json", + ], + }), + ).rejects.toThrow("exit 1"); + + expect(mocks.runtime.error).toHaveBeenCalledWith( + expect.stringContaining("Codex app-server agent runtime"), + ); + expect(mocks.completeWithPreparedSimpleCompletionModel).not.toHaveBeenCalled(); + expect(mocks.runtime.writeJson).not.toHaveBeenCalled(); + }); + it.each(["", " ", "\n\t"])( "rejects empty model run prompts before local dispatch (%j)", async (prompt) => { diff --git a/src/cli/capability-cli.ts b/src/cli/capability-cli.ts index bf7b12b6eaa..06bafa9742e 100644 --- a/src/cli/capability-cli.ts +++ b/src/cli/capability-cli.ts @@ -654,6 +654,11 @@ async function runModelRun(params: { if ("error" in prepared) { throw new Error(prepared.error); } + if (prepared.selection.provider === "codex") { + throw new Error( + 'The codex provider is served by the Codex app-server agent runtime, not the local simple-completion transport. Use an openai/ ref with agents.defaults.agentRuntime.id: "codex", run through the gateway, or use /codex commands.', + ); + } const result = await completeWithPreparedSimpleCompletionModel({ model: prepared.model, auth: prepared.auth, From 86cc29274e03368f78d2bae275dde03e925c38e0 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Sat, 2 May 2026 15:28:32 -0500 Subject: [PATCH 4/4] Wizard: bound hatch TUI timeout (#76241) * Wizard: bound hatch TUI timeout * Scripts: avoid control regex in hatch stall repro * Scripts: remove hatch stall repro harness * Changelog: note hatch timeout fix * Changelog: use PR reference for hatch timeout --- CHANGELOG.md | 1 + src/tui/embedded-backend.test.ts | 29 +++++++++++++++++++ src/tui/tui-launch.test.ts | 29 +++++++++++++++++++ src/wizard/setup.finalize.test.ts | 47 +++++++++++++++++++++++++++++++ src/wizard/setup.finalize.ts | 2 ++ 5 files changed, 108 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36316d6c9b3..655a8ca4c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Status: show the `openai-codex` OAuth profile for `openai/gpt-*` sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky. - Gateway: avoid repeated plugin tool descriptor config hashing so large runtime configs do not block reply startup and trigger reconnect/timeouts. (#75944) Thanks @joshavant. - Plugins/externalization: keep diagnostics ClawHub packages and persisted bundled-plugin relocation on npm-first install metadata for launch, and omit Discord from the core package now that its external package is published. Thanks @vincentkoc. +- Setup/TUI: bound the Terminal hatch bootstrap run so a stalled provider request times out instead of leaving first-run hatching stuck behind the watchdog. (#76241) Thanks @joshavant. - Plugins/Codex: allow the official npm Codex plugin to install without the unsafe-install override, keep `/codex` command ownership, and cover the real npm Docker live path through managed `.openclaw/npm` dependencies plus uninstall failure proof. - Gateway/status: add concrete service, config, listener-owner, and log collection next steps when gateway probes fail and Bonjour finds no local gateway, so frozen or port-conflict reports include the data needed for root-cause triage. Refs #49012. Thanks @vincentkoc. diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index f6584596477..f13811dcb60 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -429,6 +429,35 @@ describe("EmbeddedTuiBackend", () => { expect(capturedSignal?.aborted).toBe(true); }); + it("passes explicit chat timeouts to the agent command as seconds", async () => { + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + agentCommandFromIngressMock.mockResolvedValueOnce({ + payloads: [{ text: "hello" }], + meta: {}, + }); + + const backend = new EmbeddedTuiBackend(); + backend.start(); + try { + await backend.sendChat({ + sessionKey: "agent:main:main", + message: "Wake up, my friend!", + runId: "run-explicit-timeout", + timeoutMs: 300_000, + }); + await flushMicrotasks(); + + expect(agentCommandFromIngressMock).toHaveBeenCalledTimes(1); + expect(agentCommandFromIngressMock.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + timeout: "300", + }), + ); + } finally { + backend.stop(); + } + }); + it("restores embedded mode and runtime loggers on stop", async () => { const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); diff --git a/src/tui/tui-launch.test.ts b/src/tui/tui-launch.test.ts index 5fb9ae6cfc4..5a2e547fa80 100644 --- a/src/tui/tui-launch.test.ts +++ b/src/tui/tui-launch.test.ts @@ -102,6 +102,35 @@ describe("launchTuiCli", () => { ); }); + it("passes initial message and timeout through to the relaunched TUI", async () => { + const child = createChildProcess(); + spawnMock.mockImplementation((_cmd: string, _args: string[], _opts: SpawnOptions) => { + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }); + + await launchTuiCli({ + local: true, + deliver: false, + message: "Wake up, my friend!", + timeoutMs: 300_000, + }); + + expect(spawnMock).toHaveBeenCalledWith( + process.execPath, + [ + "/repo/openclaw.mjs", + "tui", + "--local", + "--message", + "Wake up, my friend!", + "--timeout-ms", + "300000", + ], + expect.objectContaining({ stdio: "inherit" }), + ); + }); + it("launches compiled CLI shapes without repeating the current command", async () => { process.argv[1] = "setup"; const child = createChildProcess(); diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index ad63061107f..aa6e237df85 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -346,6 +347,52 @@ describe("finalizeSetupWizard", () => { local: true, deliver: false, message: undefined, + timeoutMs: 300_000, + }); + }); + + it("bounds the bootstrap hatch TUI run timeout", async () => { + vi.spyOn(fs, "access").mockResolvedValueOnce(undefined); + const select = vi.fn(async (params: { message: string }) => { + if (params.message === "How do you want to hatch your bot?") { + return "tui"; + } + return "later"; + }); + const prompter = buildWizardPrompter({ + select: select as never, + confirm: vi.fn(async () => false), + }); + + await finalizeSetupWizard({ + flow: "quickstart", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: false, + skipHealth: true, + skipUi: false, + }, + baseConfig: {}, + nextConfig: {}, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: undefined, + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }); + + expect(launchTuiCli).toHaveBeenCalledWith({ + local: true, + deliver: false, + message: "Wake up, my friend!", + timeoutMs: 300_000, }); }); diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index 104c6bb1ece..8881f9065a3 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -52,6 +52,7 @@ type FinalizeOnboardingOptions = { type OnboardSearchModule = typeof import("../commands/onboard-search.js"); let onboardSearchModulePromise: Promise | undefined; +const HATCH_TUI_TIMEOUT_MS = 5 * 60 * 1000; function loadOnboardSearchModule(): Promise { onboardSearchModulePromise ??= import("../commands/onboard-search.js"); @@ -458,6 +459,7 @@ export async function finalizeSetupWizard( local: true, deliver: false, message: hasBootstrap ? "Wake up, my friend!" : undefined, + timeoutMs: HATCH_TUI_TIMEOUT_MS, }); } finally { restoreTerminalState("post-setup tui", { resumeStdinIfPaused: true });