* fix(exec): treat exact empty workdir as omitted
Small tool-calling models fill every optional field of the exec schema,
so exec arrives with workdir: "" instead of omitting the field. The
empty string was treated as a literal path and the command was refused,
wasting agent turns and surfacing misleading failures (e.g. the model
concluding the runtime is unavailable).
Normalize only the exact empty string to omitted at the workdir input
boundary; whitespace-only and nonempty invalid paths remain fail-closed.
Closes#126390
* fix(exec): align exec schema with empty-workdir behavior
The shared model-facing schema still declared blank and whitespace
workdirs invalid while the resolver now treats an exact empty string as
omitted. Update the schema description to state that an empty string
means omitted and only whitespace-only values are invalid, and update
the schema assertion accordingly.
* fix(normalization-core): preserve non-Error object causes with extra keys
The cause-chain branch of formatErrorMessage called only
formatStatusAndCode(cause) with no stringifyUnknown fallback, while the
top-level branch used formatStatusAndCode(value) ?? stringifyUnknown(value).
formatStatusAndCode returns undefined for any object whose keys are not
exactly status/code, so a non-Error object cause carrying extra keys (e.g.
{ statusCode: 429 } or { status: 503, code: "UNAVAILABLE", requestId: "abc" })
was silently dropped — appendCauseMessage(undefined) no-op'd and the loop
broke, losing the diagnostic/retryable detail.
Mirror the top-level branch: appendCauseMessage(formatStatusAndCode(cause) ??
stringifyUnknown(cause)). Behavior-neutral for causes that already render;
restores the dropped detail for the asymmetric case. stringifyUnknown is a
local helper in the same file.
Closes#126652
Co-Authored-By: Claude <noreply@anthropic.com>
* test(normalization-core): assert structured cause metadata
---------
Co-authored-by: ruel225 <ruel225@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Altay <altay@hey.com>
Make long, free-form Telegram proof runs truthful and resilient. Keep the trusted mock harness current across historical SUTs, preserve intentional silence and blocked outcomes, remove fixed attempt/lifetime caps, and export cropped motion proof without the prior memory spike.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Sessionless interval heartbeat monitor and task wakes now defer to the
configured heartbeat session, while explicit user sessions and ordinary cron
event queue ownership remain unchanged.
Maintainer replacement for #116373 because the external fork cannot satisfy
the strict Clownfish landing transaction. The replacement preserves the
accepted patch exactly on current main.
Fixes#116205.
Thanks @sloptop-the-terrible for the original patch and @QQSHI13 for the report.
Co-authored-by: sloptop-the-terrible <310909503+sloptop-the-terrible@users.noreply.github.com>
The Comfy music provider dropped req.timeoutMs when calling
runComfyWorkflow, while the image and video providers forward it.
Request-level timeouts (mediaModels.music.timeoutMs, tool parameter)
were silently ignored: shorter values had no effect and longer ones
still failed at the hardcoded 300s default.
Punchcard-Session: coral-harbor-river-yv
* fix(agents): remove a deleted agent's cron jobs on the offline delete path
Follow-up to #127037, which fixed the exec-approvals half of the same gap and
named this one explicitly.
`agents delete` tries the Gateway first and falls back to a local path. The
Gateway handler nests two transactional cleanups around the roster commit --
cron wrapping approvals wrapping the config write. After #127037 the offline
path did the inner one; it still skipped cron. So deleting an agent without a
Gateway left its scheduled jobs enabled:
$ openclaw agents delete cronprobe --force
Deleted agent: cronprobe <- no mention of cron
$ sqlite3 <state>/state/openclaw.sqlite "select job_id, name, agent_id, enabled from cron_jobs"
975cb750-... | cronprobe-job | cronprobe | 1
$ openclaw cron list
cronprobe-job every 1h Next: in 59m idle
To be accurate about severity: this is not silent. Each firing records
`error: "cron job agent is unavailable: cronprobe"` and `cron list` flips to
`error`. The defect is that the job keeps its schedule forever, and that
recreating an agent with the same id points it at the new agent.
The fallback had collapsed two different reasons into one `null` return, which
is what made the fix look unsafe at first: credential failures happen *before*
transport, so a live scheduler may still own the cron store, while an
unreachable Gateway means nothing else is holding it. `maybeDeleteAgentThroughGateway`
now returns a discriminated union, and only the unreachable branch mutates the
store directly. The credentials branch commits the roster, warns, and sets
`cronCleanupSkipped: true` in JSON.
The local `CronService` construction already existed inside
`local-request-context.ts`; it moves to `src/cron/local-service.ts` and both
callers share it rather than growing a second cron mutation path. That
extraction also switches the default-owner resolver from
`tryResolveLegacyCompatibilityAgentId` to `tryResolveAmbientOwnerAgentId`, which
is a superset -- it honors an explicitly configured
`agents.defaults.systemAgent.agentId` and otherwise falls back to exactly the
previous function. Live testing showed agentless memory-dreaming jobs need it to
load under explicit agent ownership.
Production +89/-62.
* test(agents): split the delete suite so the new cron coverage stays under the cap
The 40-line cron regression test added in the previous commit pushed
`src/commands/agents.delete.test.ts` to 1018 code lines, over the 1000 cap, and
`check-lint-core-3` went red. Repo policy forbids a `max-lines` suppression.
Unlike the earlier `cron/view.test.ts` split there was no describe-level seam --
23 flat tests in a single describe -- so the split follows subject instead. The
seven workspace-lifecycle tests (trashing, sharing, overlap, symlink reachability,
workspace-state cleanup) move to `agents.delete.workspace.test.ts`.
`vi.mock` and `vi.hoisted` are per-file and cannot be imported, so the mock
preamble and the shared `beforeEach` are declared in both files; the helper block
above them is unchanged in each. Each file then imports only what it uses, which
is why the import lists differ.
Trimming to a hair under the cap by moving only the new test was possible and
rejected: it would have left the file at ~978 code lines, back at the cap within
a couple of changes. This leaves 749 and 603 physical lines.
No test content changed: 27 passed before, 27 after.