Two pieces of dead workflow surface, found auditing where CI time goes:
- ci-timings-summary was hard-disabled (`if: ${{ false && ... }}`) with a
TODO to re-enable or delete it after the next timing-optimization
review. That review happened; the local `pnpm ci:timings` helper is what
we actually use, and docs already pointed there. The job carried a
25-entry needs list that had to be kept in sync to stay lintable.
- build-artifacts exported four `*-result` outputs that no job or workflow
reads.
Removing the job lets the gate guard assert the stronger invariant it
wanted all along: ci-gate needs *every* job in the file, so a new lane
cannot slip in ungated (28 jobs, 27 gated, zero exceptions).
No runtime behavior changes: the job could never run and the outputs had
no consumers. Also audited every `pnpm <script>` and `node scripts/...`
reference in ci.yml for rot -- all resolve.
* fix(ui): surface hidden-pane steer terminal failures globally
Three terminal branches in steer-lifecycle.ts (transport null result,
failed queue-row restore, failed queue-row removal) still gated their
error on itemStillVisible, so a steer that failed after the operator
navigated away parked the error on the queue row with no visible
outcome — the exact invariant #124473 introduced
surfaceChatDeliveryFailure() to protect.
Route all three through the canonical helper and delete the divergent
visibility-only branches. Regression test fails pre-fix
(stash-verified): steer transport failure with the pane hidden now
surfaces the session-named global toast.
* fix(logging): demote per-turn gateway log noise to debug
Live campaign evidence showed three lines dominating operator logs at
info level with no per-turn diagnostic value:
- 'tool policy removed N tool(s)': the policy pipeline runs on every
turn, so this repeated 42x in one session. Demote to debug and delete
the now-dead toolPolicyAuditLogLevel/auditLogLevel plumbing that only
existed to lower diagnostic probes to the level that is now the
default (net -13 production LOC).
- 'codex app-server one-shot cleanup checked shared client retirement':
routine per-attempt teardown detail; demote to debug.
- 'codex trajectory capture requires the SQLite host recorder': static
config condition warned per attempt; warn once per process.
Skipped: the [model-fetch] info carve-out in model-transport-debug.ts is
a named contract (docs/logging.md, #89648) — always-info by design.
* fix(codex): drop test-only trajectory warn-once reset export
Knip's production unused-export gate rejects
resetCodexTrajectoryRecorderWarningForTest — it was a test-only seam in
production code. Reset the process-wide warn-once flag via
vi.resetModules() + fresh dynamic import in the test instead.
* test(cron): wait for backoff re-arm instead of fixed sleep
The 0ms retry timer arms only after async watcher-state persistence, so
'await delay(5)' races it on loaded CI workers (flaked on
checks-node-compact-large-2: spawn called 1 time, expected 2). Replace
both fixed-sleep re-arm waits with vi.waitFor on the spawn count. The
remaining delay(5) guards a negative no-further-spawn assertion after
cancel, where a bounded sleep is the correct shape.
* fix(codex): scope trajectory recorder warn dedupe to session
ClawSweeper P2: the host recorder factory returns null for per-session
target-mapping conflicts, not only static config, so a process-wide
warn-once flag silenced a later distinct session's recorder loss. Warn
once per session (bounded set, cleared past 64 entries) so retries stay
quiet but each newly affected session records its loss. Regression
covers a later distinct session still warning.
The computer.act v1 wire contract is gone, but the naming that survived it
still described a version split instead of the real one: screen-coordinate
execution versus window/element-scoped execution. Both are live rungs of the
same ladder.
- Extract the screen-coordinate half of the 1334-line ComputerActionService
into ComputerScreenActionExecutor (dispatch, typing, scroll, coordinate
mapping, button-hold watchdog, raw CoreGraphics primitives). Moved code is
unchanged apart from threading the queue authority check as a parameter
instead of reaching back into the queue.
- ComputerActionService keeps its name and becomes the coordinator that owns
the execution queue, the permission probe, and the shared error vocabulary.
- Rename ComputerActionServiceV2 to ComputerWindowActionExecutor, isV2Request
to isWindowScopedRequest, isComputerActV2Only to isWindowScopedOnly, and
ComputerActionError.invalidV2Request to .invalidRequest. The emitted
COMPUTER_INVALID_REQUEST: prefix is unchanged.
- cua-computer: v2-actions.ts becomes window-actions.ts, handleV2Act becomes
handleWindowAct, and the stale v1Params local in handleDesktopAct becomes
desktopParams.
- Note at the computer.act idempotency key that its v1 prefix versions the key
composition, not the wire contract.
Behavior-neutral: no logic edits, no new branches, no changed error strings.
formatFileOperations in agent-core joined every accumulated read/
modified path unbounded — and the lists ratchet: each compaction merges
the prior summary's file operations into the next accumulation, so a
long default-mode session (or branch summarization, which runs in every
mode) re-emitted an ever-growing path dump into each model-visible
summary with no cap, violating the context-budget invariant.
The safeguard extension already solved this with a bounded formatter
(900 chars/list, 2000 chars/section, '...and N more' overflow). Move
that implementation into the agent-core owner so compact(),
generateBranchSummary, and the safeguard all share one bounded
formatter, and delete the safeguard-local duplicate plus its constant
copies.
* fix(cli): render gateway connect failures as an actionable message
Gateway-required commands (e.g. `agents delete` with the gateway down)
printed a bare `Error: connect ECONNREFUSED 127.0.0.1:19801` with no next
step. Wrap raw socket-level connect failures at the callGateway owner into a
GatewayTransportError that names the target URL and points at
`openclaw gateway run` / `openclaw gateway status`; every gateway CLI
call site inherits the fix.
* fix(cli): point unknown-channel errors at channels list
`message send --channel qa-channel` and agent delivery failed with a bare
`Unknown channel: qa-channel`. Route both throw sites through the existing
formatUnknownChannelMessage helper so the error names
`openclaw channels list --all` as the next step.
* fix(cli): stop console redaction from garbling models status auth counts
The auth overview line used `(oauth=1, token=1, api_key=0)`; the console
secret redactor matched `token=`/`api_key=` as credential key=value pairs
and printed `token=*** api_key=*** |` with unbalanced parens. Switch to
count-first wording (`1 oauth, 1 token, 0 api-key`) via a shared formatter
and add a regression test that runs the line through redactSensitiveText.
* fix(cli): show recorded session totals in the sessions table
`openclaw sessions` rendered `unknown/200k (?%)` right after turns while
`openclaw status` showed real numbers: the table dropped any recorded
total without freshness provenance. Align with status semantics — always
show the recorded total, withhold only the percentage when the
freshness fact is missing.
* fix(cli): middle-truncate long plugin source paths in the list table
Out-of-root plugin source paths wrapped a single `plugins list` row across
5+ terminal lines. Middle-truncate the table fallback at 48 chars so both
path ends stay identifiable; verbose and JSON output keep the full
pasteable path.
Three silent-failure sinks in the plugin lifecycle, all violating
'every action ends in a visible outcome or a recorded reason':
- cleanupReplacedPluginHostRegistry collects per-hook cleanup failures
instead of throwing (it must finish every plugin), but both callers
discarded the returned failures array entirely — broken session-
extension/scheduler teardown vanished. The shared funnel now warns
per failure with plugin and hook ids.
- setup-registry dropped broken setup entries with catch{return null}
and threw-away registration errors with catch{return false}, silently
removing a plugin's providers/CLI backends/migrations from
onboarding. Both now push typed diagnostics (setup-entry-load-failed,
setup-registration-failed) — and since the diagnostics array had no
operator surface at all, the registry build now warns once per
uncached build for every diagnostic (including the previously
invisible descriptor-drift ones).
- loadPluginCliDescriptors swallowed total load failures behind a muted
per-plugin logger; a failure removes every plugin command from
help/dispatch and must not vanish with it. One warn on the catch.
* refactor(plugin-sdk): dedupe session-catalog cursor paging into session-catalog-runtime
The acpx Pi and opencode session catalogs carried byte-identical
boundedLimit/encodeCursor/decodeCursor/optionalRawCursor/transcriptPage
scaffolding (~114 duplicated lines each). Move one canonical copy into the
private-local session-catalog-runtime SDK subpath both plugins already may
import, and keep the plugin-named cursor guards as direct aliases.
Net -87 production LOC; no behavior change (error strings, cursor canonical
form, and byte budgets are unchanged).
* refactor(core): replace private sleep() clones with canonical sleep helpers
managed-linux readiness polling now uses @openclaw/retry sleepWithAbort with
ref:false (preserving the clone's timer.unref behavior), and the embedded
agent runner's async-task wait uses src/utils/sleep.ts. Both clones matched
canonical semantics on real inputs (positive integer poll intervals).
The update-managed-service-handoff copy stays: it lives inside a serialized
standalone handoff script (String.raw template) that cannot import repo
modules.
sendMessageSlack special-cased core's silent-reply token before any API
call, returning a fabricated 'suppressed' messageId. Silent-reply
stripping is owned by core auto-reply normalization before payloads
reach outbound — no sibling channel transport has this check, so a
literal NO_REPLY sent through the message tool delivered everywhere
except Slack. The check predates the extension extraction (it moved
verbatim in 8746362f5e) and is duplicate policy.
Delete the check, its sentinel mint (the only one in the repo), and the
receipt filter for it; tests now pin sibling-parity delivery instead of
the suppression.
The active-hours guard returned skipped/quiet-hours without emitting a
heartbeat event, while every sibling skip in the same stage emits one.
'openclaw system heartbeat last' and the troubleshooting docs promise
reason=quiet-hours is observable; the silent return hid the window from
operators. Emit the same skipped event the siblings do.
build-artifacts is the wall's pole in 4 of the last 5 main runs (171-186s,
~15-20s ahead of the next lane), so its serial steps are the wall. The
Doctor plugin-index proof, singleton smoke, and startup-memory check ran
as their own 13s step even though they are independent dist readers that
the 47s artifact-check wave could absorb.
They now run inside that wave: on Blacksmith all seven start together, so
the verifiers cost the wave's max instead of 13s of serial time; hosted
runners still serialize the three through run_verifier so the RSS ceiling
measures an unloaded process. The step drops its selection gate because
the verifiers always run -- each artifact check already self-gates on its
own RUN_* flag, so a run with no checks selected still verifies.
Proof: extracted the step body and ran it with stubbed pnpm/node. Both
modes behave (Blacksmith 7 checks started, hosted-with-nothing-selected
still runs the 3 verifiers), and a failing verifier exits 1 with its
::error annotation in both -- the wave cannot swallow it.
checks-node-core-tooling-2 failed on main (run 31943910358) with
'expected 1786879225197 to be >= 1786879225197.2246': the repaired output
mtime landed a fraction of a millisecond below the input it had to clear.
isArtifactSetFresh repairs output mtimes to exactly ceil(newestInput), so
it leaves zero headroom for sub-millisecond write rounding or lagging
metadata on CI filesystems. When the repair lands at or below its input
the mtime fast path never engages, and every later invocation in that
checkout falls back to re-hashing every input byte -- the expensive path
this repair exists to avoid, gating the d.ts emit that is the slowest
build-all phase (25.9s of a 61s build).
Neither macOS APFS nor an idle Linux ext4 Testbox reproduces the
shortfall in 300 runs, so the repair now clears the newest input by a
whole millisecond instead of matching it. The assertion pins that
headroom, making the test deterministic where it was previously
load-dependent (fails 615 vs 616 without the fix).
* fix(computer-use): unblock the macOS live-rig proof flow
The rig ran its operator CLI and its proof runner from one state dir, so both
shared one device identity. A paired operator device is pinned to the scopes of
its first connect, and `nodes list` connects first for `node.pair.list`
(operator.pairing); the proof runner then needs operator.write, which is a scope
upgrade the gateway never approves silently and which no rig client can approve
for itself. The proof runner is a GATEWAY_CLIENT/BACKEND client, so on a
loopback auth-none gateway it is admitted unpaired with the scopes it asks for:
giving the CLI its own `cli-state` identity is enough, and `agent-state` now
never accumulates a pairing row.
`nodes list` also read `node.list` through the plain CLI client while
`nodes status`/`describe` used the diagnostics ladder. On any gateway where the
CLI must pair, the unfiltered list silently dropped connected/commands/
computerUse and `--connected` failed outright, so the documented rig gate could
not confirm the node. Both call sites now use `callNodeDiagnosticsGatewayCli`.
Docs drop the `devices approve <requestId>` instruction, which was circular:
that invocation is its own new device identity.
* test(cli): share the runtime-log formatter across nodes CLI e2e files
The extracted diagnostics-auth file stringified captured log arguments directly, which the type-aware core lint stripe rejects (no-base-to-string). Move the existing formatter into the shared node test helpers instead of duplicating it.
* fix(cron): keep error class names out of run history
* fix(cron): preserve lifecycle abort reason
* fix(cron): preserve coded abort reasons
* docs: record cron abort follow-up
* fix(ui): always surface terminal chat send failures
Terminal chat send failures were recorded only on the queue item when the
owning pane was not visible (reconnect, alias drift, split-pane routing),
so the operator saw nothing at all — an action ending in silence.
Root cause: every error surface in the send/drain path was gated on
visibleSessionMatches(...), and the FIFO outbox drain treated every
chat.history reconcile rejection as a silent retryable "blocked", so a
non-retryable rejection (e.g. auth loss) wedged the head — and everything
behind it — permanently with no visible outcome.
Fix, at the owner:
- New surfaceChatDeliveryFailure() in steer-lifecycle.ts (the shared
error-text owner): visible pane keeps the inline chat error; otherwise
the failure routes through the existing global toast host, naming the
session. All terminal failure sites in chat-send-delivery,
chat-outbox-drain, chat-send-queue-state, and steer-lifecycle now use it.
- reconcileStoredChatOutboxHead: a non-retryable GatewayRequestError on the
head now terminally fails a never-attempted head (unblocking the lane)
or parks an attempted head as unconfirmed — both with a visible outcome —
instead of blocking the lane forever.
- Composer disabled-reason: the reason now also renders while a draft hides
the placeholder, and the cloud-startup-pending gate gets a reason instead
of a silently disabled composer.
Regression tests: hidden-pane terminal failure surfaces via toast; wedged
head fails visibly and the lane drains the next message; attempted head
parks unconfirmed; disabled reason visible with draft text present.
* fix(ci): raise startup JS baseline for global failure surfacing and pin workspace-sync test clock
The chat send path now imports session-display naming for the global
failure toast, adding ~1 KiB gzip to startup JS (335452 B on CI's Linux
builder, still well under the 358400 B committed cap).
workspace-sync "never commands" asserted the exact dispatch timeoutMs
(777) but the impl derives it from a Date.now() deadline, so any elapsed
ms between admission and dispatch failed the exact-equality assertion on
a loaded runner. Pin the clock like the sibling timeout tests do.
* fix(ui): surface route-switched command failures and agent-scope global toast naming
ClawSweeper review findings on #124473:
- A queued local command failing after the operator navigated away hit
failCommand(error) with expose=false; the dispatcher's stale-scope
guard had already withheld the inline error, so a successful state
write recorded the failure invisibly — the silent class this PR
removes. Expose it globally when the scope is stale and the owning
pane is hidden; a stale scope with the pane still visible keeps the
failed queue chip (the new connection owns the inline surface).
- Global session rows are agent-scoped behind one shared "global" key,
so the toast row lookup could borrow another agent's label. Match the
row's agentId to the failed outbox's agent for global keys.
Both regression tests fail pre-fix (stash-verified).
* fix(doctor): never report unpersisted config fixes
doctor --fix printed "Doctor changes" panels while computing candidate
mutations, then crashed with a raw Error and persisted nothing when the
repaired candidate still failed write validation (e.g. an unknown root
key repaired alongside an unrepairable schema type error).
Root cause: the compute->print->validate->persist ordering was wrong.
Panels were printed at mutation time, but validation only ran inside the
atomic writer, after all panels were visible.
Fix at the owner boundaries:
- doctor-config-flow queues repair-mode "Doctor changes" panels in a
sink instead of printing them; preview panels still print immediately.
Committed side-effect repair notes (SQLite/filesystem) keep printing
at repair time; candidate-config notes from the repair sequence are
routed through the same deferred sink.
- io.write throws a typed CONFIG_VALIDATION_FAILED error (with the full
issue list) via a new createConfigValidationFailedError owner in
io.write-errors.
- runWriteConfigHealth prints queued panels only after the atomic write
commits, and renders a validation refusal as a "Doctor warnings" panel
stating no config changes were written plus the exact paths to fix by
hand. The contribution loop stops after refusal (same invariant as the
cron-ownership deferral) and doctor exits 1 without a raw Error leak.
Regression tests: pre-fix, the new validation-refusal e2e test fails on
the lying "Doctor changes" panel; contribution-level tests cover the
refusal note, held panels printing exactly once after commit, and no
retry of the identical candidate.
* fix(doctor): report partial persistence accurately after a later write refusal
Post-rebase CI and ClawSweeper follow-ups:
- repair-sequencing tests now assert the deferred configChangeNotes contract
(candidate-only mutation notes moved out of changeNotes by the parent
commit); committed side-effect notes stay in changeNotes.
- formatConfigValidationFailure is module-private; its guidance formatting is
covered through createConfigValidationFailedError, fixing the knip
unused-export gate.
- When the initial write pass committed and only the later post-repair write
is refused, the warnings panel says earlier fixes were saved instead of
claiming no config changes were written, and the outro says "some config
fixes were not applied". New regression test covers commit-then-refusal.
A failed message send (e.g. missing channel credentials on a fresh
install) durably rewrote the agent's folded main session route before
delivery was even attempted: prepareOutboundMirrorRoute called
ensureOutboundSessionEntry pre-send, stamping delivery.route/origin and
minting a conversations-registry row for the never-reached target. The
Control UI then showed the phantom channel identity and the composer
bound to a dead conversation.
Route resolution stays read-only in prepareOutboundMirrorRoute; the
durable write commits once per send at the first success proof:
identified platform evidence (onDeliveryResult) or plugin action
acceptance (onPluginSendAccepted), both before the in-delivery
transcript mirror so first-contact routes still create their session
row, with a post-return safety net for adapters whose results carry no
platform identity. The gateway send RPC sibling had the same pre-send
persistence and gets the same commit-on-evidence ordering.
Regression test proves a failed send leaves the seeded main-session
origin and conversation identity untouched while a successful send
still persists the mirror route; fails on pre-fix code.
Gateway agents.delete closed the agent database (unlinking -wal/-shm)
during preparation, then awaited config/cron/session-purge work that
reopened the database and recreated the sidecars. The sweep classified
those recreated files as foreign replacements ("cleanup path appeared
after deletion preparation"), preserved them, cascaded ancestor
protection over the whole agent directory, and finished the deletion
journal with exit 0 while ~157 files survived under agents/<id>/.
The deletion journal fence already blocks legitimate claims beneath
prepared paths, so a file that appears at a prepared-absent path can
only be leaked deleted-agent state: adopt its identity and sweep it
instead of preserving it. Prepared-present paths keep the existing
identity-mismatch protection for genuine operator replacements.
Regression tests: recreated WAL sidecars between preparation and
cleanup are trashed with the agent directory, and recovery sweeps a
file that appeared at a journaled prepared-absent path.
Hide model fallback and recovery notices in group and channel conversations while preserving direct-chat notices, persisted state, and lifecycle events.
Co-authored-by: NehoraiHadad <nehorai.hadad.projects@gmail.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>