broadcastInternal consumed each client's seq before building the frame,
and swallowed any error in a bare catch. An unserializable payload
(circular reference, BigInt) threw identically for every client: the
event vanished for all of them, every seq advanced, and the next good
frame fired every client's gap detector simultaneously — a synchronized
full-reconnect storm with zero server-side evidence.
Root cause: seq consumption ordered before frame construction, and
serialization failure conflated with per-client send failure in one
catch. The frame now builds before the seq is consumed; a serialization
failure aborts the broadcast once with a logged error and touches no
seqs, while per-client send failures keep consuming the seq so that
client's gap detector still sees the loss.
Regression: new server-broadcast.serialization.test.ts — a circular
payload consumes no seqs, logs once, and the next broadcast is seq 1
for every client. Fails pre-fix (no log, seqs consumed).
* fix(tasks): skip session sweep when the cron store is unreadable
readRunningCronJobIds() swallowed any cron-store load error and returned
an empty running-job set. The session-registry sweep then treated every
running cron job's transcript as prunable and archived it — a corrupt or
locked cron store silently destroyed live transcripts on
'openclaw tasks maintenance --apply'.
Root cause: failure was collapsed into the 'no running jobs' success
shape instead of a distinct outcome. The loader now returns a closed
ok/error result; an unreadable store skips the sweep entirely and the
summary reports skippedReason (JSON + human output) so the operator sees
why nothing was pruned.
The sweep moved to tasks-session-registry-maintenance.ts: tasks.ts was
at its 700-line cap and the sweep is a coherent standalone concept; its
regression tests live beside it.
Regression: new colocated test fails on pre-fix semantics (empty-set
catch prunes the possibly-running transcript).
* chore(tasks): keep SessionRegistryMaintenanceSummary module-local
No external consumer; deadcode:exports gate rejects unused exports.
* fix(tui): report each reconnect failure instead of freezing on the first
The TUI gateway client deduped connect errors with a pendingConnectError
guard that only a successful hello cleared. Against a gateway that keeps
failing for different reasons (connection refused, then pairing
required), every error after the first was swallowed, so the pairing
approval hint never reached the operator — a silent dead-end.
Root cause: dedupe scope was per-connection-lifetime instead of
per-close-cycle. Clear the guard in the onClose suppression branch so
each reconnect attempt reports its own failure cause while repeated
errors within one cycle stay deduped.
Regression: new gateway-chat.reconnect-errors.test.ts fails pre-fix
(second error swallowed) and passes post-fix.
* test(tui): update connect-error dedupe test to per-close-cycle semantics
The prior assertion encoded the freeze-forever dedupe (second socket's
failure swallowed until hello). Dedupe is now per close-cycle, so the
retry socket's first failure is reported and duplicates within that
cycle stay deduped.
`checks-ui-e2e-real-gateway` folded `hybrid` into the `github` breaker clause
instead of taking the attempt-gated route its sibling `checks-ui-e2e` uses, so
it was the only Chromium lane pinned to hosted capacity on attempt 1. Measured
across 46 canonical main runs it averaged 205s (max 225s) against a 150-190s
plateau, making it the run's slowest job whenever the compact Node matrix
behaved.
Route it like `checks-ui-e2e`: Blacksmith 16-vCPU on attempt 1, hosted on rerun,
manual dispatch, fork, and same-repo PR retry. Cache inputs stay on the shared
pnpm store for both backends, matching the sibling lane.
The guard now derives both Chromium lanes' `runs-on` from one pinned template so
a hosted-only divergence cannot return unnoticed; mutation-checked by reverting
the workflow clause (2 failures).
enqueueFollowupRun called getFollowupQueue (which inserts into the
process-global FOLLOWUP_QUEUES registry) before the recent-message-id
dedupe peek. A provider redelivery arriving after the original queue
drained and self-deleted recreated the registry entry, was rejected by
the peek, and left an empty FollowupQueueState (AbortController, arrays,
WeakSets) in the map forever — registry entries are only deleted when a
drain finishes, and a queue with no items never drains. Long-lived
gateways leaked one state object per redelivered session key.
Root cause: mutation-before-validation ordering. The dedupe key
derivation does not need the queue, so the peek now runs first and the
registry entry is only created for admitted work.
Regression: new dedupe test drains a queue to self-deletion, replays the
message, and asserts no registry entry reappears — fails pre-fix.
Two traps hit while reading a team-host session with images: hosted/systemd
installs keep state under the service user's home (root can carry a stray
install), and user-attached images live in message.__openclaw.media[] rather
than image content parts, so parts-only extractors find nothing.
A linked worktree whose base predates (or carries) wrapper changes relative
to origin/main previously hit a hard refusal, even though the canonical
checkout held exactly the trusted origin/main wrapper the refusal message
told the operator to go run by hand. When the canonical checkout is clean
and byte-identical to fetched refs/remotes/origin/main, exec it with a loud
stderr notice instead; advisory dev-wrapper opt-in keeps precedence, and
the refusal remains when no anchor-matching wrapper exists on disk.
Also records the squash-merged stacked-branch rebase gotcha
(git rebase --onto origin/main <landed-branch>) in the PR maintainer skill.
The auto local-STT default hardcoded
/opt/homebrew/share/whisper-cpp/for-tests-ggml-tiny.bin — a CI
provisioning artifact — as the production whisper model. Real users with
brew-installed whisper-cpp and downloaded models got ready: false
('model file not found'), silently skipping local transcription; test-
provisioned machines transcribed real audio with the worst-quality tiny
fixture with nothing recording that a test artifact was chosen.
Discover ggml-*.bin models across standard install dirs, preferring
non-tiny models; WHISPER_CPP_MODEL stays the explicit override. Not
found now genuinely means no model is installed.
Every other exec-description namespace hint (API, MCP, swarm, skills) is
gated on actual availability, but nodesGuidance was unconditional. The
nodes bridge resolves the owner-only openclaw:core:nodes tool, which
non-owner and policy-restricted runs filter out of the catalog — so the
prompt advertised a namespace whose every call fails with 'Unknown tool
id ... use tools.search', burning execs on guaranteed dead ends.
Gate the hint on the compacted catalog containing the nodes tool id,
matching the sibling guidance pattern (unknown catalog keeps the hint).
Share one lifecycle-owned reservation primitive between reply admission and foreground delivery ordering, and remove the obsolete admission-wait callback plumbing.
The requirePairing closure interleaved five non-interactive approval lanes
(silent-local, trusted-CIDR, trusted-proxy, setup-code bootstrap, Control UI
bootstrap) with request creation, inline approval, concurrent-approval
recovery, and SSH kickoff — the shape that let a silent-policy contradiction
(the removed scope-upgrade veto) hide for months. Lane eligibility now
resolves in one typed top-level function, resolvePairingApprovalPlan,
returning a closed plan the closure executes; behavior is intentionally
byte-equivalent and locked by the existing connect suites.
* fix(gateway): refuse startup migrations when a live gateway owns the state directory
An accidental gateway run against a live-owned state dir (e.g. missing
OPENCLAW_STATE_DIR) ran legacy-dir relocation migrations before the runtime
lock acquisition refused readiness, mutating the owner's state on a startup
that never served. Probe the gateway lock identity before acquiring the
startup migration lease and refuse readiness first; the runtime lock stays
owned by the run loop. Regression test seeds a migratable legacy agent dir
under a live-locked state dir and asserts the refused run leaves it untouched.
* fix(gateway): refuse for a live state-dir owner before the first startup mutation
ClawSweeper found the initial gate still ran after mutation-capable work:
config-health recovery in gateway pre-bootstrap opens the shared state DB,
whose write admission quarantines orphaned SQLite sidecars (copies a nonempty
WAL/journal to .orphaned-* when the main DB is absent). Probe the live owner
at that first write boundary (pre-bootstrap suspicious-config recovery) and
again at preflight entry before state write admission; the lease-boundary
re-probe stays for the wait window. Regression now also seeds an orphan WAL
and asserts the refused run leaves the state DB directory byte-untouched.
* test(gateway): use a gateway-shaped child as the live lock owner
On Windows getFileLockProcessStartTime returns null, so the lock reader
validates the owner through process argv; the Vitest parent's argv is not a
gateway command, making the fixture read as a dead owner there. Spawn a
gateway-argv-shaped child and record its pid/start time in the lock instead.
* refactor(gateway): delete the retired Control UI device-auth migration window
The one-time remediation window for the retired
gateway.controlUi.dangerouslyDisableDeviceAuth break-glass threaded a pending
state machine through the connect pipeline, startup bootstrap, lifecycle,
request context, device management, security audit, the hello frame, and a
Control UI banner flow — steady-state runtime carrying a retired-shape shim
the architecture reserves for doctor. The window is closed: the retired key
is now fully inert, doctor still detects and removes it, and a browser that
never completed the migration pairs through the normal device flow (one
approval, no data loss).
Deleted with it: the config-machine-state import of the legacy flag, the
hello deviceAuthMigration field (optional; clients validate envelopes only,
so older gateways emitting it stay compatible), the migration-bound device
management authz states, the requireNoPairingCapableOperator approval mode,
the effective-operator pairing event emitter whose only subscriber was the
migration completion, and the Control UI banner, loader, overlay wiring,
i18n strings, and e2e scenario support. Swift and Kotlin protocol models
regenerated.
Live-verified on an isolated gateway with the retired key present in config:
clean boot with no migration warning, silent CLI pairing and silent local
scope widening unaffected, and doctor reporting the key as retired-and-inert.
Net -1111 production LOC.
* fix(ci): finish migration-window cleanup surfaced by the gates
The doctor migration for the retired key kept its "Preserved for remediation"
change text and describe; both now state plain removal, with the retired
tests updated. peekStoredDeviceIdentityId lost its only production consumer
with the deleted banner loader and is removed with its test mock. Also fixes
the unrelated no-unnecessary-boolean-literal-compare lint break that #124636
landed on main in scripts/check-changed.mts (truthiness is equivalent for the
boolean-or-undefined TTY probe).
* fix(ui): drop the retired-key device-auth reader from the security summary
Review findings on the migration-window removal: the Control UI security
summary still derived its "Device auth" row from the retired
dangerouslyDisableDeviceAuth key, rendering device auth as disabled on
configurations that merely retain the inert key. Device auth is now
unconditionally enforced, so the row and its derivation are removed rather
than pinned to a constant. The build-admission test also tracks and removes
its temporary device-identity databases after each run.
The throttled budget kick swallowed every sweep failure into an empty
catch. Retrying on the next kick is right, but a persistently failing
sweep (corrupt store, permission loss) meant unbounded disk growth with
no operator signal — silent failure on the default maintenance path.
Warn with the error and store path before the retry.
The never-retry test pins Date.now specifically so the derived dispatch
timeout is deterministic, then asserted only <=777 — which also accepts
zero and would mask a broken deadline derivation. Assert the exact value
the pin guarantees.
* docs(config): correct compaction mode help text default claim
applyCompactionDefaults has written mode: "safeguard" for unset configs
since dd1b08b3e8, and docs/concepts/compaction.md documents that. The
schema help string still told operators to 'Keep "default"' as if it
were the shipped default — prompt/config text contradicting shipped
behavior. Align the help string with the actual default.
* test(qa-lab): tolerate the residual sleep race in stalled-probe bound
The stalled-versions-probe test asserted sleepImpl is never called, but
the inner AbortSignal.timeout (clamped to remainingMs) and the outer
deadline timer race at ~timeoutMs: when the inner timer fires first the
loop legitimately takes one residual sleep before Date.now() crosses the
deadline. Flaked on CI shard changed-extensions-config-27 for an
unrelated help-string PR. Protect the real invariant — bounded exit with
at most one deadline-clamped sleep — instead of the timer-ordering
artifact.
* test(qa-lab): type the stalled-probe sleep mock parameter
check-test-types rejects indexing an empty-tuple vi.fn mock call
(TS2493); give sleepImpl its real (ms: number) signature.
* fix(scripts): use the interactive flag directly in virtualization probe
no-unnecessary-boolean-literal-compare (type-aware lane) rejects
'(options.interactive ?? process.stdin.isTTY) === true'; both operands
are boolean, so use the expression directly. Broke check-lint on main
via 55240929f5a; fixed in this landing PR per the broken-CI default.
resolveFacadeModuleLocation only cached successful locations, so every
lookup for a plugin that is not installed re-walked the bundled-plugins
dir and registry on every request — request-time freshness polling of
process-stable install topology, which the architecture doctrine forbids.
Cache null results too. Install/reload/doctor flows already clear this
cache through registerPluginMetadataProcessMemoLifecycleClear, so a
newly installed plugin becomes visible exactly when the lifecycle owner
says so — the retry-on-appear test now pins that contract instead of
accidental per-call refresh.