## What Problem This Solves
When a scheduled automation keeps failing, OpenClaw posts "Automation X failed N times" to the chat and waits for the user to ask for a fix, even when the fix is something the agent in the conversation that created the job could make itself.
## User Impact
When a job created from a conversation (it has an owner session) reaches its failure-alert threshold (default: 2 failures in a row, same cooldown and incident dedupe), OpenClaw sends one repair request to that owner conversation instead of the first chat alert. The conversation handles it as an ordinary agent turn, as if it had received a message: its own session and transcript, its workspace, its normal tool policy, and its reply goes to that conversation's own route (chat, thread or forum topic). The request tells it to:
- fix the problem in the workspace if it can (for example the helper script or instructions file the job follows) and say what it fixed in one line;
- otherwise ask the user for exactly what it needs;
- say nothing if the failure was transient.
Heartbeat settings do not apply to the repair: `heartbeat.target: "none"`, `isolatedSession`, `lightContext` and `activeHours` do not route, isolate, trim or hold it, and it carries no first-heartbeat notice.
The normal alert is still sent when the job has no owner conversation, for operator-only jobs (command payloads, on-exit and stream schedules), for webhook alert routes, and when the job fails again after the repair request (once, noting that a repair was requested). A successful run clears the streak silently.
**Upgrade behavior change (intentional):** repair is on by default. Opt-out is per job: `failureAlert: false` disables both the repair request and the alert. No config key is added. The chat "Automation … recovered" notice is removed; recovery stays in automation history. This reverses the notice added in #146582.
## Why This Change Was Made
The owner conversation is where these jobs get fixed in practice: in our setup a user had to reply "just fix it" to a failure notice, and the agent in that conversation then repaired the job. This change starts that step automatically.
The repair is dispatched through the Gateway's existing `agent` method on the instance's lifecycle principal (`dispatchGatewayLifecycleMethod`, the same owner restart-sentinel continuations and exec-approval follow-ups use), with the owner `sessionKey` and `deliver: true`, so the agent method resolves the reply route from that session's stored delivery context, thread included. The cron service reaches it through one injected dependency (`runCronFailureRepair`, wired in `server-cron.ts`), so `src/cron` does not import Gateway code. Nothing in the `sessions_send` tool needed extracting: its default path also calls `agent`, but with `deliver: false` plus its own announce flow, which is what this does not want.
An earlier revision carried the request on a heartbeat wake (system event plus `requestHeartbeat`) and patched the result with a `heartbeat: { target: "last" }` override. That made the repair inherit heartbeat behavior it should not have: an isolated `:heartbeat` side session without the conversation's transcript, light context, the internal-handling prompt, and heartbeat routing. This revision removes that path (system event, heartbeat wake, override, and their docs and tests); heartbeat behavior is unchanged for everything else.
**Tradeoffs (maintainer decisions):**
- The request is scheduler-authored, not a user message: it runs on the Gateway's system principal with `internal_system` provenance (`sourceTool: "cron_failure_repair"`), so it carries no sender-owner identity. Under the existing trust model the turn gets the conversation's normal non-owner tool policy (workspace and exec tools, not owner-only control tools such as `automations`); a change to the job itself goes through the user's reply turn. No new authority plumbing.
- The job's name, payload and last error are wrapped as untrusted data in the request.
- The request goes to the job's owner at dispatch time. If it is lost (the job was removed in between, or the `agent` dispatch rejects), nothing extra is sent at that moment; the job's next failure sends the normal alert naming the repair.
- A repair reply is posted at any hour, like the failure alert it replaces.
Persisted state: the incident gains an optional `repair: { atMs }` marker. It means "this incident's first alert became a repair request". It survives restarts, and the next alert of that incident clears it.
## Evidence
- **Size vs `origin/main`** (merge base `accde9e31a`, head `435e097ee7c`): production +233/−85 (net +148), docs +14/−4, tests/QA +649/−49; 21 files. To stay within the line-cap ratchet, the Gateway dispatch lives in `server-cron-notifications.ts` next to the failure-alert transport (2 lines of wiring in `server-cron.ts`), `reconcileCronExitWatchers` moved to `cron-exit-watchers.ts`, and two input helpers moved from the qa-lab mock server to `mock-openai-input.ts`.
- **Our production shape, Telegram Test Server forum topic, live OpenAI model** (`telegram-e2e-userbot`, Convex-leased credential, the lease's forum supergroup with a run-owned topic created before recording and deleted after; fresh isolated gateway from this checkout via `--source-gateway` on ports 19951/19952; provider slot = logging proxy to the OpenAI API). Config mirrors ours: `agents.defaults.heartbeat = { every: "1h", activeHours: 08:00–23:00 Asia/Kolkata, target: "none", directPolicy: "allow", to: <the tester's DM>, lightContext: true, isolatedSession: true }`, `cron.failureAlert` and `messages` unset. The QA user posts in the topic, which creates the owner conversation `agent:main:telegram:group:<forum>:topic:<n>`. A command action seeds `scripts/meeting-sync.md` (step 1: `sleep 150`), adds an isolated agentTurn job "Meeting sync" owned by that topic and announcing to it (`timeoutSeconds: 45`), and force-runs it twice: both time out (`consecutiveErrors: 2`, notification status `not-requested`).
- Command (secrets omitted): `E2E_MOCK_SERVER_PATH=<logging proxy to the OpenAI API> E2E_ROOT_CONFIG_PATCH=<live model + the heartbeat block above, messages: null> node <runner with forum-topic setup hooks> --backend mock --source-gateway --gateway-port 19951 --mock-port 19952 --chat <lease forum> --scenario <scenario> --timeout-ms 1000000 --record events.ndjson --output summary.json` → exit 0.
- Topic timeline (all messages):
| Elapsed | Topic message (SUT unless noted) |
|---|---|
| 0.8 s | user: `Hi! This topic owns our hourly sync automations. Reply with one short sentence confirming you are here.` |
| 61 s | `I’m here in this topic for your hourly sync automations.` |
| 272 s | `Fixed scripts/meeting-sync.md by removing the 150-second wait that exceeded the automation’s 45-second timeout.` (the repair turn) |
| 393 s | `Sync: 3 new meetings.` (third run, `ok`, `consecutiveErrors: 0`) |
| 514 s | `Automation "Calendar sync" failed 2 times` / `Cause: timeout` / `Run started: …` (the unowned control's alert) |
| 620 s | user: `What did you change in the meeting sync automation earlier? One sentence.` |
| 651 s | `I updated scripts/meeting-sync.md to read the CSV immediately instead of waiting 150 seconds, which exceeded the automation’s 45-second timeout; the schedule stayed unchanged.` |
- The repair ran in the topic session itself: `chat.history` of `agent:main:telegram:group:<forum>:topic:<n>` holds, in order, the owner-ready exchange, the repair request (user role, `internal_system`), `read` → `exec` → `exec` → `edit scripts/meeting-sync.md` → `exec`, the one-line reply, the next run's result, and the follow-up exchange. No `…:topic:<n>:heartbeat` session exists (sessions list). Its 6 main-model requests carried the brief with the topic's ordinary tool surface. The step file changed about 30 s after the second failure.
- No `Automation "Meeting sync" failed …` alert and no `First heartbeat alert` text anywhere (7 SUT messages). Control: an unowned job "Calendar sync" with the same broken step fails twice and alerts (delivery status `delivered`); its step file stays unchanged. The provider log also shows one `NO_REPLY` turn without the brief right after the repair's `exec` calls that sent nothing.
- **Telegram Test Server DM owner, live OpenAI model, `activeHours` excluding now** (same harness with `--dm`, window 06:00–07:00 Asia/Kolkata at 21:06 IST, otherwise the same heartbeat block). The DM creates the owner conversation `agent:main:main`; the same owned job fails twice (timeouts). DM timeline: 29.5 s `I’m here in the chat that owns your hourly meeting sync automation.`, 310.8 s `Fixed meeting sync’s timeout by replacing its 150-second blocking wait with a nonblocking export-readiness check.` (the repair turn; it chose to delegate the edit to a subagent via `sessions_spawn`/`sessions_yield`), 339.1 s `Sync: 3 new meetings.` (third run `ok`). The repair request and reply are in `agent:main:main`'s transcript; no alert, no onboarding notice, 3 SUT messages.
- **Regression test** (`src/gateway/server.cron-failure-repair.test.ts`, new file, "repairs an owned job with an ordinary owner-topic turn whatever the heartbeat config"; 4.3–5.8 s): a real Gateway with its real cron service and `agent` method (agent command mocked at the runtime boundary), heartbeat `target: "none"`, `isolatedSession`, `lightContext` and an `activeHours` window that excludes now; an owned job owned by a Telegram topic session fails twice; the agent command runs once in `agent:main:telegram:group:<g>:topic:42` with `deliver: true`, `channel: "telegram"`, `to: <g>`, `threadId: 42` and the repair request as its message, and no announce is sent. Fails on the previous head (no agent run).
- **qa-lab end to end** (`cron-failure-repair-owner-conversation`, real gateway child, qa-channel, mock-openai, isolated state): `OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1 pnpm openclaw --profile <isolated> qa suite --provider-mode mock-openai --scenario cron-failure-repair-owner-conversation` → `passed=1 failed=0`. An owned job fails twice and no alert is sent; the owner conversation receives the repair request, its ordinary turn fixes the workspace step file (`write`), the reply lands in that conversation, and the request and reply are in the owner session's own `chat.history`. Control: an unowned job with the same failures alerts, and the repaired job never does.
- **Service tests** (`src/cron/service.failure-repair.test.ts`, 11 tests): threshold → one `runCronFailureRepair` request to the owner session (no system event, no heartbeat wake) and no alert; the job's name, payload and error sit only inside `<untrusted-text>`; the next failure sends one alert naming the repair, later failures nothing more and never a second repair; a rejected repair request alerts on the next failure; success clears the incident silently; no owner → the old alert; agentTurn, systemEvent and script jobs repair, command jobs and on-exit or stream schedules alert. The heartbeat-route case in `src/cron/service.wake-now-real-heartbeat.test.ts` is removed (the file is back to `main`).
- On `435e097ee7c`: `node scripts/check-changed.mjs` exit 0 (line-cap ratchet, all tsgo shards, lint, import cycles, dead exports); cron repair/alert/persistence/real-heartbeat, gateway cron, cron-exit-watchers, server-cron, notifications and qa-lab mock-openai suites pass (30 files, 538 tests); `pnpm config:docs:check` is clean.
- **Existing-state upgrade (recorded on an earlier head, harness not kept):** published `openclaw@2026.9.6` created an owner session, an owned and an unowned agentTurn job with an announce failure route and each job's first failure; its own `openclaw update --tag <candidate tarball>` succeeded and both streaks and owners survived; one more failure each reached the threshold: the owned job's incident gained `repair: {atMs}` with delivery status `not-requested` and its repair request reached the model; the unowned job alerted. The persisted state is unchanged by this rework.
- **Config compatibility:** no config key is added or changed (`config:docs:check` OK). The behavior change is the default for owned chat-alerting jobs, documented above; per-job `failureAlert: false` opts out.
- Downgrade: a persisted `repair` marker is an extra field older releases ignore; an incident that was mid-repair at downgrade keeps its signature there, so the older release dedupes that same-cause failure (no new alert until the cause changes or the job recovers), as it does for any already-alerted incident.
- **Owner boundary:** `owner` is fixed at creation (`CronJobPatch` omits it), so it cannot be reassigned; dispatch reads the live job and sends nothing if the job is gone.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
## What Problem This Solves
Fixes: a chat turn that stalls (slow model or tools, then aborted by stuck-session recovery) posts "⚠️ This turn was interrupted because it stopped making progress. Please try again." and leaves the user to re-ask, even though the transcript already holds most of what's needed to answer.
## User Impact
When an interactive turn stalls before sending any output, and its request is already saved in the transcript:
- **Nothing from that sender is waiting in the follow-up queue (the normal channel case):** no notice. OpenClaw starts one recovery turn on the stalled turn's route and authority. It answers from the existing transcript, keeps tool use minimal, doesn't repeat actions and says what it couldn't verify. Channel messages that arrived during the stall, from this sender or another one sharing the session, run afterwards as their own turns.
- **The same sender already has a follow-up in the queue (for example `chat.send` with `queueMode: "followup"`):** no notice. That follow-up runs next with a runtime note that the stalled turn's request is still unanswered, so it answers that request together with the new message. Follow-ups from other senders or routes sharing the session are never given the stalled request. They get a separate recovery turn instead.
- **The recovery turn, or the follow-up that took over the request, also stalls:** the existing notice is sent as a last resort. There is never a second recovery.
- **A Web UI (`chat.send`) or group-thread participant turn with no queued follow-up from the same sender:** the existing notice is sent. These turns reply only through their own source-bound delivery, which a separate recovery turn can't use, so its answer would be dropped.
- **The turn stalls before its request is saved** (for example during preflight compaction): the existing notice is sent, because a transcript-only recovery couldn't see the request.
Cron, heartbeat, optional-reply turns and stalls after output are unchanged.
It also fixes a custody bug in dispatch that is already on `main`. A channel message queued as a follow-up behind a busy session could get "⚠️ OpenClaw couldn't produce or deliver a reply. Please try again." while that follow-up was still waiting to run. This happened when the busy turn ended just before the message's dispatch took the session back for final delivery. Dispatch now keeps the queue's `accepted` custody instead of overwriting it with the late `owned` admission.
## Why This Change Was Made
On a live Telegram gateway in our setup, a user asked a simple restaurant question while the network was throttled. The turn spent 15 minutes researching, was aborted, and the user got "Please try again". The very next queued message answered correctly from the same transcript within a minute.
The recovery reuses the existing session follow-up lane (`enqueueFollowupRun` plus drain after the reply operation clears), so there is no new queue. The recovery run is replay-safe: the inbound message isn't replayed or re-persisted, and inbound dedupe and turn adoption are unchanged. When the queue hands the request to an existing follow-up, it matches on the queue's existing route key plus the collect-batching authorization key (sender, operator authority, owner and exec overrides). If the stalled turn's operator authority is no longer current, nothing is continued and the notice path runs as before. A recovery turn is otherwise an ordinary queued follow-up under the queue's existing admission, abort and authority checks (maintainer decision in the PR thread).
## Accepted tradeoffs
- **Cancelling a follow-up that took over a stalled request stays silent.** A targeted `chat.abort` of that queued follow-up is a deliberate cancel. No retry notice is sent afterwards ([maintainer decision](https://github.com/openclaw/openclaw/pull/161069#issuecomment-5906733068)).
- **No extra send-time authority fence.** The recovery turn is an ordinary queued follow-up under the queue's existing admission, abort and authority checks. Authority is checked when the continuation is queued and again at follow-up admission, the same as any queued follow-up ([maintainer decision](https://github.com/openclaw/openclaw/pull/161069#issuecomment-5904051646)).
- **Web UI and group-thread participant turns without a queued same-sender follow-up keep the notice.** Giving a standalone recovery turn a live source-bound delivery owner would need new machinery, so this PR scopes recovery to channel turns and the same-sender follow-up handoff ([maintainer decision](https://github.com/openclaw/openclaw/pull/161069#issuecomment-5908163404)).
## Evidence
- **Telegram Test Server with a live OpenAI model**, head `0eca7a4` (Recovery) and `e0b05c5` (the other cases) (`telegram-e2e-userbot` skill, Convex-leased credential, real QA user in a DM, `QA_DIAGNOSTIC_STUCK_SESSION_ABORT_MS=30000`, `diagnostics.enabled`, a fallback model, and provider `timeoutSeconds: 30` so the stuck detector's model allowance matches the QA abort bound). A live model can't be made to stall on demand, so a proof-only local proxy sits in front of the real OpenAI API. For a stalled turn it reproduces the QA mock's stall shape: 4 main-model requests that fail after 8 s, then 1 held open until the abort. Everything else goes to the API unchanged. The question is "What is 17*23? Reply with just the number.":
- **Recovery:** the QA user's message lands at +0.2 s. Stuck recovery aborts the turn at 54 s (`stuck session recovery … action=abort_embedded_run`, then `cause=skipped:reply_operation_aborted`). One recovery request carrying the stall guidance goes to the live model, which returns `391`. The only SUT message is `391` at +61.3 s, with no retry notice. Main-model requests: 5 stalled, 1 forwarded. The same result held at `e0b05c5` (+61.1 s).
- **The recovery also stalls** (the proxy stalls the guided request the same way): there are two stuck aborts (ages 53 s and 59 s). The only SUT message is the existing notice "⚠️ This turn was interrupted because it stopped making progress. Please try again." at +118.3 s. Main-model requests: 5 stalled plus 5 recovery requests stalled, none forwarded, and no second recovery.
- **Queued same-sender follow-up** (`messages.queue.mode: followup`): the QA user asks 17*23 at +0.2 s and "Also, what is 19*21? Reply with just the number." at +20.2 s, while the first turn is stalling. After the abort, the queued follow-up runs once with the stall guidance and the live model returns `391` and `399`. There's one SUT message at +60.4 s, no notice, no "couldn't produce or deliver a reply" fallback and no separate recovery turn.
- **The queued follow-up also stalls:** same two messages, and the proxy also stalls the follow-up's guided requests. There are two stuck aborts (ages 53 s and 60 s). The only SUT message is the notice at +117.9 s.
- **Defect this found on `c8a2682`:** with the old guidance ("Answer the user's outstanding request now"), the live model took the newer message as the outstanding request and replied only `399`. The stalled question went unanswered, with no notice. The guidance now says the stalled turn's request is still unanswered and asks for an answer to it together with any newer message. The same scenario then answered both questions, at `313f58a` and again at `e0b05c5`. The QA mock needle ("previous turn stopped making progress") is unchanged.
- Runner wall times: 170 s, 202 s, 222 s and 224 s.
- **Real Gateway `chat.send` (Web UI path) with a live OpenAI model**: a standalone Gateway from the built head, the same stall proxy, and an operator WebSocket client (the TUI's `GatewayClient`) that sends the question with `chat.send` and records the `chat` broadcasts:
- Head `0eca7a4`: the turn stalls (5 stalled main-model requests) and stuck recovery aborts it at 50 s. The client receives `state: "error"` with "⚠️ This turn was interrupted because it stopped making progress. Please try again." for its run at +57.9 s, then `aborted`. No recovery request is sent.
- Head `e0b05c5` (before this fix): same stall and abort. The client only receives `aborted` for its run. The recovery turn's result is dropped by the chat.send reply owner (`webchat late reply disposition … outcome: late-and-dropped, reason: terminal-not-recorded`). The user gets neither an answer nor the stall notice.
- **Telegram Test Server** (`telegram-e2e-userbot` skill, Convex-leased credential, real QA user in a DM, `--backend qa-mock`, `QA_DIAGNOSTIC_STUCK_SESSION_ABORT_MS=30000`, `E2E_ROOT_CONFIG_PATCH` enabling `diagnostics` plus a fallback model so the turn keeps retrying, 180 s recording window). Head `c8a2682`:
- The QA user sends one message at +0.2 s. The only SUT message is `STALLED-TURN-RECOVERED-OK` at +59.9 s, after typing rows. There's no "Please try again" or any other SUT message in the window.
- Provider requests from the QA mock: 5 stalled requests (4 on the primary model, then 1 held on the fallback model until the abort), then exactly 1 recovery request carrying the stall guidance, which succeeded. The mock only returns the marker for a request that carries the guidance.
- Gateway log: `stuck session recovery outcome: status=aborted`, then `visible channel turn dispatched with no queued reply payloads … cause=skipped:reply_operation_aborted`, then a single `telegram outbound send ok`.
- Runner wall time: 203 s.
- The runner drops `*_SESSION_*` env keys from child processes as secret-shaped, so for this run a `NODE_OPTIONS` preload set the QA stuck-session bound on the gateway process only. The harness isn't changed in this PR.
- **qa-lab `stalled-turn-auto-recovery`** (real gateway, qa-channel, mock-openai, `session.dmScope: main`, `messages.queue.mode: followup`). Sender A's turn loops on failing model requests until QA-tuned stuck recovery aborts it. Sender B DMs the same main session while A's turn is running. Isolated profile, state dir, config path and gateway port per run, with gateway logs kept (`OPENCLAW_QA_KEEP_TEMP=1`), 6 runs in parallel:
- Head `c8a2682`: **18/18 passed**. Earlier head `dbc5955` (same runtime code): 12/12 passed. Every run: A's DM gets exactly `["STALLED-TURN-RECOVERED-OK"]`, B's DM gets exactly `["GATEWAY_REPEATED_REQUEST_QUEUED_OK"]`, and the mock logs `stalledRequests=5 recoveryRequests=1 bystanderRequests=1` with the recovery request before B's. Suite wall time at head under 6-way load: median 132 s, range 81–156 s. A single unloaded run took 70 s.
- Same build with only the custody fix reverted: **10/15 failed**. 8 failed because B got the generic "couldn't produce or deliver a reply" fallback while its queued follow-up was still pending. This is the intermittent failure reported on the previous head. 2 more timed out after B's dispatch failed with `restart recovery claim changed before agent adoption`. That failure also didn't occur in any run with the fix.
- **`gateway-repeated-request-recovery` gateway e2e** (same-operator `chat.send` follow-up): passes in 102 s (file wall 173 s). The queued follow-up carries the stall guidance, and no extra recovery turn runs.
- **Unit tests** (real `runReplyAgent` with the real follow-up queue):
- One transcript-only recovery run when nothing is queued.
- The same sender's next queued follow-up gets the guidance and is protected from overflow eviction.
- Another sender's queued follow-up on another route is left untouched, even when a same-sender follow-up sits behind it. A separate recovery run bound to the stalled sender's route and sender drains first.
- Revoked stalled-turn authority: no continuation and no exception.
- Heartbeat: no continuation is set up.
- Dispatch: the notice is sent only when nothing continues the turn. A stall after output continues nothing. A queued channel turn stays `accepted` when the busy session frees before final admission. This test fails without the custody fix: `queuedFinal` is `true` and the fallback is sent.
- Follow-up delivery: the last-resort notice when the recovery run itself stalls, silence in the other aborted cases.
- Wall times: `agent-runner.stalled-turn.test.ts` (new file, 5 tests) takes 26.6 s wall on its own, and its tests run for 2.8 s. The changed `dispatch-from-config.terminal-recovery.test.ts`, `followup-delivery.test.ts` and `agent-runner.stalled-turn.test.ts` together take 36 s wall (93 tests). `extensions/qa-lab/src/suite-planning.test.ts` takes 14 s wall (28 tests).
LOC vs `origin/main`: production +252/−25 (core reply +213/−24, qa-lab harness/mock +39/−1). Tests/QA +680/−9 (including the 170-line qa-lab scenario). Docs +1. The qa-lab, gateway e2e and older Telegram evidence above ran at `c8a2682`. `agent-runner.stalled-turn.test.ts` passes at `0eca7a4` (8 tests, 31 s wall). Its new Web UI test uses the Gateway's real chat.send reply owner and fails at `e0b05c5` (continuation returns `true`). With the production files reverted to `313f58a`, the claimed-follow-up double-stall (followup and collect) and pre-persistence stall tests fail.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
## What Problem This Solves
Fixes: a Telegram group posted "⚠️ Yield failed" between a successful acknowledgement and the generated image when the model called `sessions_yield` to wait for `image_generate` (our Claw, v2026.9.6).
Live sequence: `image_generate` starts a detached media run → `sessions_yield` is rejected ("No pending child completion is owned by this turn…") → `message` send with `final: false` ("Making the … now") → empty final text → "⚠️ Yield failed" → 26 s later the completion turn delivers the image.
Removing that warning exposes a second false notice: the turn then ends with "I finished the turn, but it did not produce a visible reply. Please try again…", because a progress ack is not a final reply and nothing records that the in-flight media run owes the result. Current main shows that notice whenever a turn only starts `image_generate` and ends empty (4 of 5 live turns below).
## User Impact
When the model hands an image, video, or music request to a generation run, the chat sees an acknowledgement and then the result, without "⚠️ Yield failed", the "please try again" notice, or a generic failure message. If the model sends its own acknowledgement, that is the only message before the result; if it ends the turn silently, the chat gets the standard "I’m continuing this work and will send the result when it is ready." status. Real tool failures, including genuine `sessions_yield` failures, still warn when nothing was delivered.
## Why This Change Was Made
- **Advisory yield is not a tool failure.** With nothing to wait for, `sessions_yield` now returns `status: "nothing_pending"` (not an error), like the existing non-error `deferred` and `already_pending` results. The model still gets the guidance; the text now also says background media runs deliver their result as a later turn. Genuine failures are unchanged and stay tool errors: missing session context, unsupported context, refused claims (cron, collector, subagent background exec), and exceptions from the claim or yield callbacks. The shared tool-terminal observer is untouched.
- **No missing-reply notice while a media run is in flight.** The existing accepted-spawn continuation (`continuationPending`) also covers a visible turn whose otherwise-empty result is owed by a media run started in that turn. It applies only while that run is still running, with no real tool failure and no final source reply; non-final progress sends count as acknowledgements. A run that already finished keeps normal handling. A media continuation has no completion-owning child to take over delivery, so the reply layer delivers its waiting status and skips the child handoff (also when the turn only accepted a fire-and-forget child) (the handoff requires accepted children and previously threw "accepted continuation status could not be prepared for delivery", which the chat saw as "Something went wrong while processing your request").
- **Model-facing text no longer says "await".** The `image_generate`/`video_generate`/`music_generate` descriptions and their started/status results said "call once/request, await completion" and "Wait for the completion event", which led models to call `sessions_yield`. They now say the result returns as a later turn and this turn should give at most a short acknowledgement, then end. The `sessions_yield` description says it is not for tool results or background tool runs.
Maintainer intent: `git log --author=pash@openai.com --author=sjf@openai.com` over the touched files is empty. `payloads.ts` and the warning policy are unchanged. Intentional silence and compact-mode behavior are unaffected.
LOC vs `main`: production +66 / −25, tests, QA and docs +356 / −41 (includes 26 prompt-snapshot lines and the QA scenario).
## Evidence
**Live model, Telegram Test Server, group chat.** `telegram-e2e-userbot` runner with a Convex lease; the provider slot is a logging passthrough to a live OpenAI model, with real image generation. `messages.visibleReplies` and `messages.groupChat.visibleReplies` = `message_tool`. Five turns, one image request each, 150 s apart, 900 s recording. Same config, prompts and passthrough for both builds; baseline is the PR's merge-base.
```
E2E_MOCK_SERVER_PATH=<logging passthrough> OPENCLAW_QA_ALLOW_LOCAL_IMAGE_PROVIDER=1 \
E2E_ROOT_CONFIG_PATCH='<live model + image model + message_tool visible replies>' \
node .agents/skills/telegram-e2e-userbot/scripts/run-mock-sut-user-e2e.mjs \
--gateway-port <port> --mock-port <port> --scenario scenario.json \
--timeout-ms 900000 --record events.ndjson --output summary.json
```
| | merge-base | this PR (3ad7a7e, before the handoff fix) | this PR (4c6289e, with the fix) |
| --- | ---: | ---: | ---: |
| image requests | 5 | 5 | 5 |
| images delivered | 5 | 5 | 5 |
| `sessions_yield` calls | 1 | 0 | 0 |
| "⚠️ Yield failed" | 1 | 0 | 0 |
| "did not produce a visible reply" notice | 4 | 0 | 0 |
| "Something went wrong while processing your request" | 0 | 5 | 0 |
| acknowledgement before the image | none | none | model ack (1) or waiting status (4) |
| provider requests (chat / image) | 33 / 5 | 28 / 5 | 34 / 5 |
4c6289e timeline, first two turns (seconds from the first send; the `Working` draft is deleted each time):
| t | event |
| ---: | --- |
| 0.4 | user: make an image of a cat reading a newspaper |
| 28.8 | SUT: "On it—a cat catching up on the morning news. 🐈📰" (`message`, non-final) |
| 45.2 | SUT: photo |
| 150.2 | user: make an image of a dog riding a bicycle |
| 158.8 | SUT: "I’m continuing this work and will send the result when it is ready." |
| 179.6 | SUT: photo |
Turns 3–5 match turn 2. The gateway log for the 4c6289e run has no `outcome=error` dispatches; the 3ad7a7e run logged one per turn.
After rebasing onto current `main` (which reshaped the continuation owner in the reply layer), a two-turn rerun on the rebased build (same setup, 330 s recording): both turns showed the waiting status at 17.0 s and 157.8 s, then the photo at 46.8 s and 180.4 s; 0 `sessions_yield` calls, 0 warnings or failure messages, 0 `outcome=error` dispatches (9 chat and 2 image requests).
Merge-base timeline, first two turns: user request → `sessions_yield` (rejected) → "⚠️ Yield failed" at 19.7 s → photo at 49.9 s; user request → "I finished the turn, but it did not produce a visible reply…" at 158.1 s → photo at 186.4 s.
The baseline model yielded on 1 of 5 turns, so the wording change's effect on yield frequency is small in this sample (1 → 0). The user-visible failures are gone either way: no warning, no missing-reply notice, no failure message.
**Telegram Test Server, group chat, `--backend qa-mock`** (scripted: image_generate → sessions_yield → `message` `final:false` → empty final, 45 s recording): ACK, then the photo; no "⚠️ Yield failed" and no empty-reply notice.
**QA Lab** (mock-openai, qa-channel group, same visibility config): `yield-rejection-no-warning` passes on this branch (`sessions_yield status=nothing_pending`; outbound = ACK, then image; no error-marked outbound). The merge-base run with only the QA changes delivers ACK, **"⚠️ Yield failed"**, image.
**Unit tests** (wall time per file, `pnpm test <file>`):
- `sessions-yield-tool.test.ts` (3.0 s wall): no-claim and unavailable-claim rejections return `nothing_pending` and `isToolResultError(result) === false`; missing session context, unsupported context, and a refused claim stay errors.
- `terminal-resolution.test.ts` (29.4 s wall): a running media run with no ack, a plain progress send, or a non-final source reply resolves to `continuationPending`; a finished run, a real tool failure, or a delivered final reply keeps normal handling.
- `agent-runner.runreplyagent.e2e.test.ts` (161 s wall, 224 tests): a media-run continuation with no children, or with only a fire-and-forget child, delivers the waiting status and settles. Without the fix the first fails with "accepted continuation status could not be prepared for delivery" and the second fails at settlement; the existing child-continuation cases pass unchanged.
- `sessions.initial-transfer-custody.test.ts`: the empty-yield case now expects `nothing_pending`.
- `openclaw-tools.requester-yield.test.ts` (36.9 s wall): generic no-claim expectations updated to `nothing_pending`.
- Also passed: music/video status tests, `tool-terminal-outcome.test.ts`, prompt snapshots, qa-lab scenario catalog, and mock tool routing.
Out of scope, pre-existing on main: if a media run fails and its completion turn produces no text, a group gets no failure message (the announce fallback covers only subagent completions and DMs).
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* test(qa): align DM routing sender with conversation identity
The portable routing scenario used different direct-conversation and sender identities, which Telegram correctly rejects before ingress. Reuse the configured direct-conversation identity for the sender without changing channel policy.
Regression: channel-dm-group-routing failed twice through Crabline Telegram with the direct/sender normalization error on 5eeecd39f9. The corrected existing scenario passes through Crabline Telegram, Crabline Discord, and qa-channel on Blacksmith Testbox with mock-openai and isolated state. No production changes; scenario diff is +1/-1. Local hooks are disabled because this campaign prohibits local execution; final changed checks run remotely.
* test(qa): preserve one sender across routing scenario turns
Keep the direct and group turns on one participant alias. The real Telegram QA adapter binds a single leased participant to its first logical alias and correctly rejects a later alias switch. Exercise the shipped routing flow through that adapter with the existing mocked userbot, without credentials or a Gateway process.
Completes the DM routing fixture repair; production code and channel policy stay unchanged. Final remote proof checks the new regression against the prior fixture and then the corrected fixture. Local hooks are disabled under the campaign host restriction.
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Release-check lanes that were also red on main asserted pre-change contracts:
- subagent settle wording now says "in this batch" (#158642)
- Responses request bodies are pre-encoded bytes (#159574); decode them
without consuming init instead of requiring string bodies
- direct experience-review calls must bind the session MCP scheduler that
Gateway startup binds in production (#159481)
- Docker lanes pin the plain PATH `codex` CLI for the Codex harness probe
- Telegram group-policy hot reload must name every array it replaces
Landed directly from #160746 (the native publisher needs a signed merge to
bring that branch current). Focused proof: four QA suites 200/200 in 81 s on
the merged tree; the two E2E files and credentialed live paths were not run.
* fix(qa): recognize settled subagent batches
Share requester-settle wake recognition between mock input classification and completion handling. Accept the current batch wording while retaining installed-candidate session wording and the existing provenance check.
The real catalog-only handoff spawned and completed a child on the baseline, but QA ignored its completion and timed out. Two focused regressions failed before this fix. The real handoff now passes, all 70 owner/sibling tests pass, and check-changed passes. Standalone changed-test wall times: input 3.21s; handoff 33.76s.
* fix(qa): admit canonical repository checkpoint commands
* fix(ui): stabilize Run Inspector evidence collection
Bind rendered evidence to the public run, execution, and selected decision
receipt identities. Add a shared collector that uses the component-owned
route model and a separate page, preserving the caller's Chat surface and
unsent draft through collection and later reload.
Cover exact run/execution selection, receipt cursor reload, back navigation,
wrong requested identity, missing receipt, and page cleanup in the existing
mock-Gateway Chromium harness. Document the collector and per-tab auth setup.
The regression failed on baseline before product edits because the rendered
run identity attribute was absent. Terminal R's optional assistant transcript
idempotency key is a distinct producer gap; this does not manufacture that
key, relabel historical cells, or change product appearance.
* fix(telegram): confine QA runtime and preserve readiness evidence
Run standard-library drivers through existing Python without UV inline-script virtual environments. Confine private runtime state, drain readiness pipes, retain structural diagnostics, and require verified teardown receipts before releasing recovery state. Preserve doctor, recovery, group, and published-upgrade callers.
Validation: native sandbox regression failed before the fix; 140 harness tests pass, and 72 final focused owner/sibling tests pass in 5.73s. Build exits 0. Broad changed checks stop on two unchanged TS2459 package-update test errors. Focused lint matches all 135 baseline findings with zero additions. No live credentials or Telegram sends.
* fix(telegram): avoid native scenario barrier watchers
* fix(qa): share production publication guard contract
* fix(qa): require named message availability in mock provider
A catalog dispatcher does not advertise every delivery tool. Require the
exact message declaration and a usable invocation surface, including trusted
system/developer instruction carriers. Finish with the recovered child result
when message is absent.
Prove the absent-message regression through the mock HTTP provider and retain
catalog delivery, namespace, text-only fanout, and subagent handoff coverage.
* fix(qa): pass checkout roots to script scenarios
Expand the selected repoRoot separately from each scenario outputDir in the
maintained test-file runner. Prove the argument and CWD contract through a
real subprocess with external artifacts and fresh passing producer evidence.
* fix(qa): distinguish tool declarations from instruction prose
* fix(qa): bind forked-context evidence to native receipts
* fix(qa): bind repeated-ingress MCP scheduler
* fix(qa): add owned provider continuation checkpoints
Let maintained mock scenarios hold one session continuation before response
bytes and observe its request cursor and tool-call identity. Reuse the
provider request log and scenario signal/shutdown lifecycle; replacement
requests proceed normally without touching Gateway decision state.
* fix(qa): keep harness proofs within their owners
* test(qa): exclude all registered runtime consumers
* fix(qa): preserve runtime inputs and enforce checkpoint launches
* test(qa): skip runtime proofs when tools are absent
* fix(qa): complete checkpoint launcher test admission
* test(qa): compile repeated ingress child before execution
#160308 deleted src/agents/worktrees/service.run-end-cleanup.test.ts, which the
managed-worktrees-workboard-lifecycle scenario still listed as a codeRef, so
extensions/qa-lab/src/scenario-catalog.test.ts failed on main. The surviving
run-end cleanup outcome coverage lives in service.test.ts (late claims, stale
lifecycle writes) and service.removal-safety.test.ts (dirty retention).
Refresh application, plugin, native, build and container dependencies through the fixed 2026-09-19T16:27:11Z cutoff. Migrate native TypeScript snapshot/printer APIs while preserving compilation and filesystem contracts; retain existing patches and compatibility holds. Document offline container-image preparation.
Include the verified compiler process-census and loading-clock fixture repairs and deterministic warm-history regression. Adopt the canonical production history fixes from #159924 and #159955.
Land under the maintainer's explicit approval to treat proven pre-existing CI failures as non-blocking and repair main afterward. CI36365552098 failed an unchanged Android Compose fixture's asynchronous catalog projection assertion (3518 passed,1 failed); the Android/Gradle tree matches its main parent byte-for-byte. Security and dependency reviews passed. The final rebase preserves reviewed source changes and regenerates only the intentional Node-image documentation fingerprint. See PR159401 for complete validation and the follow-up repair obligation.
* fix(slack): keep top-level turns quiet and finish progress without "Working"
Surface: Slack channel plugin progress. Requested by Peter Steinberger.
Default progress turns without a reply thread now leave only the final
answer, using the temporary hourglass reaction when typingReaction is
unset and honoring configured reactions or an explicit disable. Preserve
explicit progress presentations and native-card selection for threads.
Finish untitled Block Kit cards as Done or Failed and native summary rows
as Completed or Failed, preserving authored headlines. Remove duplicate
Working fallbacks and build both session links from the dispatched session
key with the configured session.mainKey.
Derive terminal notification and screen reader fallback text from the same
rendered blocks, including queued-turn finalization, instead of reusing
working-state text.
Document the defaults and protect delivery, reaction cleanup, terminal
titles, and session links at the Slack wire and renderer boundaries.
Keep wire-trace helpers in test support to meet the existing line cap.
* test(qa): cover quiet top-level Slack progress
* test(infra): drain migration fixture databases before cleanup
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(slack): resolve verified linked requester profiles
Release note: Slack users linked to an administrative profile can ask an
agent to assign a session to them from ordinary messages and app mentions
received through Socket Mode or signature-verified HTTP. Generic trusted
requester metadata now carries the canonical linked profile ID and display
label; Discord verified senders benefit from the same core path. The
sessions tool remains owner-only.
Mint transport assurance at native reception and preserve it through the
existing durable ingress kind. Keep relay and mixed-assurance batches
asserted. Preserve the exact native Slack sender ID so the host identity
handoff matches finalized SenderId instead of rejecting its lowercase form.
Resolve requester facts through the existing worker-backed identity owner.
Bind their private carrier to the admitted sender, account, and channel,
recheck context and live link authority at prompt use, and retain no new
stored identity fields. Consolidate shared message-source and slash input
types to keep existing large modules within their line-growth limits.
Validation: 199 tests passed across five serial single-file Vitest runs,
including real Socket/HTTP receiver preparation, relay replay, linked
admin/member/unlinked senders, unlink/relink, genuine-carrier transplant
rejection, and persisted sessions.assignOwner through the agent tool.
Regressions failed before their corresponding repairs. Independent Codex
review is scoped-clean through P2; git diff --check passed.
The once-authorized check-changed run stopped at line-growth violations.
Those were repaired and affected tests rerun; the one-run host limit left
that gate unrepeated and typecheck/lint unrun. No live deployment was tested.
Follow-ups: native slash commands still need host-bound requester context;
assignment for non-owner channel requesters needs a separate permission design.
* fix(channels): reuse requester facts and keep prompts stable
Forward the single prepared linked-identity result, including known absence,
through command-owner authorization instead of resolving it a second time.
Keep the existing live authority checks and owner-only sessions policy.
Release note: linked requester profiles and short assign-to-me guidance now
live in host-generated per-turn conversation info, keeping the system prompt
byte-stable across different or unlinked senders. Use the canonical account
default and preserve lowercase Slack allowFrom and stored pairing approvals
while retaining native sender ID case for identity handoff.
Correct the cross-boundary Slack test fixture to use the maintained public
artifact loader, retain the context builder overloads, return synchronous
avatar data, and await preparation after SDK socket events. No SDK export,
protocol, configuration, schema, or permission expansion is introduced.
Validation: single-file authority tests 40 passed; Slack authorization tests
57 passed, including real persisted lowercase pairing approvals and a wrong-
sender negative control. New read-count and prompt-cache regressions failed
before the repair. Affected fixture cases passed again after type/lint fixes.
Full check-changed passed core/extension and test typechecks, lint, all guards,
and its six Doctor-contract tests. Independent Codex review of the final
staged candidate is scoped-clean through P2; git diff --check passed.
* test(qa): cover verified Slack requester assignment
Prove signed Slack HTTP ingress carries a linked administrative requester
profile in per-turn user context and offers the owner-only sessions tool.
An unlinked sender receives neither fact. Seed only the personal-profile
prerequisite while the ephemeral Gateway is stopped; use public role/link RPCs.
Capture strict Crabline readiness before runtime API traffic, retain the
unaltered runtime recorder, and probe the same adapter at final capture.
The recorder regression fails before the repair and passes afterward.
The optional model-issued assignment diagnostic still fails at the separate
admitted operator-authority boundary, despite a real shared target existing.
Record this coordinator gap without broadening permissions or claiming full
assignment success. Native slash requester context remains a follow-up.
Validation: signed-ingress QA passed on Blacksmith Testbox; focused tests,
applicable lint/typecheck/guards, and independent review through P2 passed.
The one full changed-file run exposed stale installed fs-safe; restoring the
pinned version fixed core types. Remaining core-test failure is fixed on main
by e9af8ebcde. No changelog, schema, protocol, or SDK surface changes.
* test(qa): register requester profile fixture entrypoint
Declare the process-launched QA fixture as a Knip executable root so its dependency graph remains audited. The exact failing unused-file scan now passes for both production and the full tree; independent review is clean through P2.
* fix(gateway): act as the linked admin for channel turns with verified owner authority
Carry the prepared linked administrator through the existing admitted operator authority. Preserve live command-owner, role, model, and access-grant restrictions for every dispatch and active model execution. Retain the original revocation failure for shared consumers. Prove visible-session assignment and fail-closed lifecycle behavior without changing tool gating, schemas, protocol, or SDK exports.
* test(qa): prove linked Slack admins can assign agent-owned sessions
* test(gateway): preserve original operator revocation assertions
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(tool-search): run tool_search_code in the QuickJS sandbox
Tool Search code mode spawned a Node --permission child with a node:vm
guest. Under Bun it needed an installed Node, and without one an explicit
code config silently downgraded to structured tools mode.
Run the guest through the Code Mode executor contract with the bundled
quickjs executor on every runtime. openclaw.tools.search/describe/call use
the shared namespace bridge with lazy thenables; the host admits only those
three operations through ToolSearchRuntime. codeTimeoutMs still bounds the
whole invocation, now including executor preparation. A denied or disabled
code-mode-quickjs plugin fails explicitly with next-step guidance instead of
falling back.
Remove the child source, IPC types, stderr-tail handling, and the Node and
Electron capability probe.
* refactor(tool-search): retire the tool_search_code bridge
Keep structured Tool Search and generic Code Mode as the two large-catalog surfaces. Remove the superseded JavaScript bridge and its runtime, display, and QA paths.
Doctor migrates legacy code mode to tools and removes codeTimeoutMs while preserving activation. toolSearch: true now selects structured search; JavaScript orchestration uses Code Mode exec/wait.
* test(tool-search): cover runtime behavior through structured controls
Exercise retained catalog, policy, hook, cancellation, terminal, MCP and client behavior through structured controls and their runtime owner. Delete bridge-only sandbox and JavaScript envelope cases while preserving nested call-id compatibility.
* test(tool-search): drop retired code mode prompt case
* test: repair fixtures exposed by Tool Search retirement checks
Remove the remaining retired Tool Search mode row. Preserve the session reader owner through the media retention mock and remove an unreachable queued-only branch from the ACP controls/submission fixture.
* test(upgrade-survivor): seed retired Tool Search code mode config
Author the legacy mode and timeout through every supported representative baseline CLI recipe, then require structured search and timeout removal after candidate update and Doctor. Existing config validation proves the resulting effective config.
Include the diagnostics native assignment summary in frozen target staging; the required assertion suite exposed its missing import. Node recipe and assertion tests: 238 passed, 157.87s wall. Docker validation remains with the coordinator.
* refactor(tool-search): drop the retired code-mode recovery surface
* chore: shrink assertion baseline after Tool Search retirement
* test(tool-search): drop the unused Tool Search test API
* chore: drop the retired Tool Search test API assertion baseline
* fix(e2e): drop duplicate native assignment staging line
Main now stages native-assignment-summary.mjs for frozen upgrades itself; the branch copy from the Tool Search upgrade proof became a duplicate after merging.
* build(pr): list Tool Search migration in wrapper inventory
The scripts/pr wrapper loads the Doctor config migrations at runtime, so the new Tool Search retirement migration belongs in its extracted component inventory.
* test(e2e): ship the Tool Search recipe to prepared tooling workers
Prepared tooling workers copy only listed source-relative assets, while the
upgrade survivor config recipe reads every section file by name at import.
The new tools-tool-search.json was missing, so the Docker scheduler parent
signal test's runner died with ENOENT and its polling wait reported a generic
5 s timeout that looked like a flake.
List the asset, guard the recipe directory against the preserved list, and
make the scheduler readiness wait fail with the runner's stderr once it exits.
Remove Tasks and TaskFlow runtime, APIs, CLI, SDK surfaces and panels after the Cron, session, native execution and media completion ownership cutovers. Preserve stored rows and import provable legacy native assignments through Doctor; ambiguous ownership stays untouched with a warning.
Follows #158221, #158217, #158225, #158222, #158702 and #158776. Related: #156532. Task-specific public APIs retire immediately; retained responsibilities use their existing owners.
Maintainer-authorized administrative landing after full CI run 36312986498 attempt 2 passed on 274595e2, with subsequent actual conflicts reviewed and focused checks passing. Current PR CI preflight hits the 64 KiB changed-path metadata limit before tests (run 36335042695); its duplicate security-review status mirrors that planning failure. Review and scoped proof are recorded in the PR. Published 9.4 native import is proven; remaining native completion and 9.4 rollback witnesses are explicitly unproven.
Point the thread memory and personal reply scenarios at the existing SDK protocol owner after the private forwarding barrel was removed. Reproduced the missing-reference failure on clean main; all 56 catalog tests pass after the two metadata fixes. Formatting and independent P2 review pass.
## What Problem This Solves
Fixes: owners who ask their agent in chat to change a key, a config value, or one of their own skills get refused, sent to a dashboard, or told to file a Workshop proposal. Examples: "you can't post API keys here", switching the embeddings provider routed to the web-search wizard, only proposals for handwritten skills.
## User Impact
An owner can hand the agent an API key or token in chat, ask it to change config such as the embeddings provider, and have it edit skills they own. Session permission modes are unchanged: Full Access applies, restricted sessions ask.
- **Keys from chat.** Masked setup flows still keep keys out of model context and remain the default. If the user already pasted a key or token, the agent stores it in the shared secret store and points the config key at it with a `store` SecretRef instead of refusing. It never echoes the value back. The pasted message already reached the model provider and transcript; redaction covers later logs and output only, and the docs say so.
- **Existing store entries are never touched.** Each save inserts a new entry named after the config key plus a random suffix (`GATEWAY_REMOTE_TOKEN_9B139B5E231299BC`). Nothing is overwritten, revived, or deleted, and a new name can never match anything already pointing into the store, including a stale reference to a removed and purged entry. Replacing a key leaves its previous entry for `openclaw secrets store rm`. Rotating a key keeps its configured store provider alias, and the audit records the alias actually used.
- **Embeddings.** The agent now treats the memory embeddings provider, model, and key as `memory.search.*` config, not the web-search setup wizard.
- **Skills.** When the user asks, the agent edits skills they own directly: repository skill source, workspace `skills/`, project `.agents/skills/`, and configured extra skill directories. Bundled, ClawHub-installed, and plugin-provided skills are replaced by their owners' updates. For those, the agent says so and offers to capture the change as a Workshop skill.
- **Tone.** The "never request / paste credentials in chat" lines are gone from the `openclaw` tools and system-agent prompts. "Never echo secret values" stays, and so do factual pointers for flows that genuinely need a UI: channel sign-in, provider OAuth/accounts, and model onboarding.
No config option, schema, or protocol change. The Full Access permission-policy floor from the first revision moved to #158142 for its own security review.
## Why This Change Was Made
After #149870, approved config writes may target any path. What still blocked owners was model-facing text telling the agent to refuse credentials, plus the missing ability to store a chat-provided value anywhere but plaintext config.
`config_set_ref` gains an optional `secret` argument (read without trimming; only emptiness is checked). With it, the system agent:
1. registers the value for redaction when the proposal is built;
2. keeps the key's existing store provider alias when it has one;
3. sends one `secrets.writeForConfigRef` command to the SQLite state worker with the requester's live-authority guard. The host re-checks that guard at the worker's transaction and commit admission (`createSqliteWorkerWriteAdmission`), so a run stopped while the command is queued writes nothing. The transaction inserts a new row under a freshly minted `NAME_<16 random hex>`;
4. writes the ref through the existing config writer, which re-checks authority. If that write fails (before or after the writer commits), OpenClaw rereads the config and the error says the key was saved as `<NAME>` and whether the config key points at it. There is no automatic delete: another consumer may have linked the fresh entry, or the writer may have committed before failing;
5. the normal config reload picks up the new ref, since its id always changes.
Nothing new runs SQLite on the Gateway main thread.
<details>
<summary>Out of scope / follow-ups</summary>
- Found while proving this: in Full Access, after a delegated change applies, the next agent turn in the same chat fails with `SQLite database already belongs to another worker backend`. It reproduces on unmodified `origin/main` (`71bb516`) with a `logging.level` change followed by one more message. This PR does not fix it.
- Built-in provider sign-in and model onboarding stay handoffs; they own live verification of the active inference route.
- Other secret-store set/delete paths remain synchronous migration debt, as `worker-access.md` already records.
</details>
## Evidence
Real Telegram Test Server (Convex-leased userbot, fresh Gateway, QA mock provider, Full Access, tester is owner), first revision:
| | Screenshot |
|---|---|
| Token given in chat, applied with no approval prompt and no refusal (synthetic QA token) |  |
### Final effects at this head
qa-channel scenario `system-agent-owner-trust` passes through a real Gateway and state worker. The Gateway is seeded with an unrelated `GATEWAY_REMOTE_TOKEN` entry, then:
1. A command-allowed **non-owner** (`bob`) sends the key. The `openclaw` tool is owner-only.
2. The **owner** (`alice`, Full Access) sends it.
Captured step details (redacted by the Gateway; the store ref id prints as `__OPENCLAW_REDACTED__`):
```json
{
"nonOwnerEntryNames": ["GATEWAY_REMOTE_TOKEN"],
"storedRef": { "source": "store", "provider": "default", "id": "__OPENCLAW_REDACTED__" },
"storeEntryNames": ["GATEWAY_REMOTE_TOKEN", "GATEWAY_REMOTE_TOKEN_9B139B5E231299BC"]
}
```
- After the non-owner turn: only the seeded entry exists and `gateway.remote.token` is unset.
- After the owner turn: `gateway.remote.token` is a `store` SecretRef, the token is in its own minted entry (`GATEWAY_REMOTE_TOKEN_9B139B5E231299BC`), and the seeded entry's `updatedAt`/`updatedBy` are unchanged. No approval prompt was posted, and the token is absent from chat, config, and the store listing.
**Revoked request**, through the production worker (Node main thread, real broker, `writeSecretStoreEntryForConfigRef`). The requester's guard passes the caller's check, then reports the run stopped:
```text
seeded: [ 'GATEWAY_REMOTE_TOKEN (cli)' ]
revoked request rejected: requesting run is no longer active
after revoked request: [ 'GATEWAY_REMOTE_TOKEN (cli)' ]
owner request saved as: GATEWAY_REMOTE_TOKEN_F1657B971F691824
after owner request: [ 'GATEWAY_REMOTE_TOKEN (cli)', 'GATEWAY_REMOTE_TOKEN_F1657B971F691824 (openclaw)' ]
seeded value intact: true
```
Tests (each fails without the behavior it covers):
- production worker path (`secret-store-config-ref.worker.test.ts`, forked database-worker lane with the real broker): a chat secret gets its own minted entry beside a live `GATEWAY_REMOTE_TOKEN` without touching it; a requester revoked after the caller's check writes nothing;
- store kernel: a refusal at commit admission rolls the transaction back; each save mints a new `NAME_<hex>` and leaves the key's previous entry unchanged; a stale name whose entry was removed and purged still resolves to nothing after a chat save;
- operations: stored and referenced with no value in output or audit; authority gone before the store write writes nothing; a failed config write names the saved entry and leaves it in place; rotating a key keeps its configured store provider alias, and the audit records it;
- tool: proposes a store write without repeating the key, preserving leading and trailing whitespace.
Measured single-worker wall time per new or materially changed test file at this head (`node scripts/run-vitest.mjs run <file>`, local M-series; vitest Duration includes import and setup):
| File | Tests | Wall | Vitest duration |
|---|---:|---:|---:|
| `src/secrets/store/secret-store-config-ref.worker.test.ts` (new, database-worker lane) | 2 | 14 s | 2.05 s |
| `src/secrets/store/secret-store.test.ts` | 32 | 16 s | 13.37 s |
| `src/system-agent/operations.test.ts` | 43 | 18 s | 15.30 s |
| `src/agents/tools/system-agent-tool.test.ts` | 36 | 15 s | 12.89 s |
QA scenarios: `system-agent-owner-trust` (mock-openai) runs in about 27 s after build; `skill-owner-direct-edit-live` is live-frontier only and took about 3 min with `claude-cli/claude-sonnet-4-6`.
Wording pins for the removed lecture text were deleted. The focused store, worker, exclusivity, operations, tool, approval, and delegate suites pass. `node scripts/check-changed.mjs` passes every gate except core lint, which fails only on three files this PR does not touch (`server-chat-metadata-lifecycle.integration.test.ts`, `session-companion-ask.ts`, `app-sidebar-session-list-render.ts` over `max-lines` on the base); oxlint on the changed files is clean.
Security decision: a Full Access owner's pasted key goes to the Gateway-wide team store without a separate approval. Maintainer (@obviyus) accepted this in the PR conversation.
**Rotation with a second consumer**, through the production worker (Node main thread, real broker). A second consumer references the key's first entry before the next save lands; value fingerprints only:
```text
owner saves key #1 -> MODELS_PROVIDERS_OPENAI_API_KEY_6D96F6E92B59E935 (sha256:4a5c5a4aa8de)
second consumer now references MODELS_PROVIDERS_OPENAI_API_KEY_6D96F6E92B59E935 (e.g. linked while the next save is queued)
owner saves key #2 -> MODELS_PROVIDERS_OPENAI_API_KEY_4066F18ABAAE6903 (sha256:28bc4e3fe10d)
second consumer's entry MODELS_PROVIDERS_OPENAI_API_KEY_6D96F6E92B59E935 after rotation: sha256:4a5c5a4aa8de
unchanged: true
```
**Config write fails after the save, with a second consumer on the fresh entry**, through the production worker and the system-agent apply path (fingerprints only):
```text
owner result: Saved the secret as GATEWAY_REMOTE_TOKEN_6B68C031E571F81C, but could not point gateway.remote.token at it: config write failed after commit (rollbackStatus: not-restored). Retry, or remove the entry with `openclaw secrets store rm GATEWAY_REMOTE_TOKEN_6B68C031E571F81C`.
config gateway.remote.token: null
second consumer's entry GATEWAY_REMOTE_TOKEN_6B68C031E571F81C: sha256:09ae5b4fd36b
second consumer keeps the credential: true
```
**Stale reference to a removed and purged entry**, production worker for purge and save:
```text
purged rows: 1
stale ref GATEWAY_REMOTE_TOKEN after purge: SECRET_STORE_NOT_FOUND
chat save -> GATEWAY_REMOTE_TOKEN_8F698B5396990ADD resolves (value hidden)
stale ref GATEWAY_REMOTE_TOKEN after chat save: SECRET_STORE_NOT_FOUND
```
**Owned-skill edit with a live model.** New scenario `skill-owner-direct-edit-live` (live-frontier; run with `claude-cli/claude-sonnet-4-6`, subscription auth) passes at this head. It seeds workspace skill `qa-owner-greeting` replying `OWNER-GREETING-V1`, and the owner asks in plain words: "Please change my qa-owner-greeting skill so it replies OWNER-GREETING-V2 instead of OWNER-GREETING-V1." Captured result:
Skill file after the turn:
```markdown
---
name: qa-owner-greeting
description: Greets the owner with a fixed marker
---
When the user asks for the owner greeting, reply with exactly: OWNER-GREETING-V2
```
Agent reply: "Let me find the skill file. Done. The `qa-owner-greeting` skill now replies `OWNER-GREETING-V2` instead of `OWNER-GREETING-V1`."
The model edited the skill file in place and confirmed it; it did not refuse or file a Workshop proposal.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* refactor(cron): read run history through its worker
* refactor(cron): clean up retired history type exports
* test(cron): finish history type and ratchet cleanup
* test(cron): prove published upgrade history reads
* test(cron): retain canonical failover type contract
* ci: refresh Cron checks after main import repair
Re-evaluate the unchanged reader against main after 7c4866c73b repaired the Team Reports test import. Production and upgrade-harness source remain unchanged.
* fix(cron): complete wrapper cutover and upgrade diagnostics
* test(cron): complete source references and delivery-free upgrade seed
* test(codex): keep catalog progress on one fake clock
* test(cron): align invalid run id assertion with owner
* ci: refresh Cron checks after async task main repair
* ci: refresh Cron checks after main QA reference repair
* ci: refresh Cron checks after Linux fixture repair
* ci: refresh Cron checks after workspace quota repair
* ci: refresh Cron checks after main progress type repair
Recognize positive-count native history truncation markers in the correlated tool output while retaining read and follow-up evidence checks.
Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: RomneyDa <6581799+RomneyDa@users.noreply.github.com>
* refactor: share filesystem admission, walks, and cleanup with fs-safe
* style: satisfy lint in fs-safe adoption changes
* test(daemon): map fs-safe reads into launchd fixtures
Keep the real fs-safe reader and its descriptor admission on the fixture backing files. Load fixture mocks before filesystem consumers and move shared state out of the oversized launchd suite.
* test(gateway): assert missing transferred manifest by errno
* fix(qa): drop retired Telegram formatter reference
The formatter refactor in #158164 folded format-render.ts into format.ts. Keep the existing format.ts code reference and remove the obsolete path so the scenario catalog reference check follows the current owner.
* fix(qa): preserve exact worktree cleanup identity
Keep the original bigint device and inode comparison when replacing the local forwarding helper. The general fs-safe matcher tolerates unknown Windows identity fields and cannot authorize destructive cleanup. Four real-directory receipt regressions fail the previous candidate; 35 cleanup/runtime cases and changed checks pass, with clean independent review.
## What Problem This Solves
Fixes: when an agent asks OpenClaw to change config, restart, or manage plugins/channels/agents, the approval never reaches chats without a native approval card, and `/approve <id>` rejects it. The agent's tool call then blocks until the 10-minute expiry, and users are sent to the Control UI or a terminal to approve a change they asked for in chat.
## User Impact
User impact: every approval an agent needs can be completed in the chat where it was requested.
- Native approval cards (Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Teams, iMessage, Google Chat) keep owning their chats.
- Every other requesting chat now gets the change summary with **Allow once** / **Deny** buttons where the channel renders them, plus a `/approve <id> allow-once|deny` line.
- `/approve` now resolves OpenClaw change approvals, not only exec and plugin approvals.
No config options, protocol, or schema changes.
## Why This Change Was Made
Delegated OpenClaw changes ("system-agent" approvals) already had native cards (#134670). Two gaps remained outside those cards:
- **Request delivery.** The request was created with delivery turned off, so the shared approval forwarder never posted a fallback. The forwarder now has a system-agent strategy that always targets the requesting chat and is suppressed whenever that chat's native card handles the request. It uses the same typed-button payload and resolved/expired messages as exec and plugin approvals. The system-agent owner publishes the outcome of a decision (applied, or denied) once; approval publication only adds expiry and cancellation, so a denial produces one chat update. The fallback answers only the live messaging chat that made the request: terminal and Webchat requests never fall back to the session's saved chat, and expiry is reported once, from the Gateway's recorded expiry rather than a local chat timer, so a change approved just before the deadline and applied after it reports its applied outcome instead of a false expiry.
- **`/approve`.** The command probed only exec and plugin approvals. It now also resolves system-agent approvals through the canonical `approval.resolve`, after confirming the id is in `openclaw.approval.list`. Canonical resolution records a kind mismatch as a deny, so `/approve` confirms the owner first instead of probing. On channels with their own approver settings (Telegram, Discord, Slack, …), the Gateway checks the sender as the reviewer, the same check as native buttons. Everywhere else only a configured owner (`commands.ownerAllowFrom`) can approve an OpenClaw change: `/approve` sends the sender as the reviewer, and the Gateway checks owner custody against the current config inside the approval store's final decision guard. An ordinary command-authorized sender is refused, and an owner removed after sending `/approve` cannot complete the decision.
Unchanged: approval authority stays bound to the requesting run; free-text "yes" never approves; Full Access still auto-applies; Control UI and the apps can still decide.
Agent-facing text now says what happens: for runs from messaging channels, the `openclaw` tool description says the change waits for approval in this chat (buttons or `/approve`). Webchat and terminal runs, which the chat fallback cannot reach, are told to approve in the Control UI or OpenClaw apps. The gateway-only prompt line points to `openclaw` and `/restart` instead of "ask human".
## Evidence
Real Telegram (Test Server userbot, Convex-leased credentials, fresh Gateway, mock provider), restricted `exec` mode, agent asks OpenClaw to `set logging.level "info"`:
| Run | Setup | What the user saw | Tap | Result |
|---|---|---|---|---|
| A | tester is owner (native cards on) | 🔒 native approval card, no `/approve` text | **Allow Once** | `answerCallbackQuery` + 2× `editMessageText`: "approved. Applying" → "approved and applied"; final reply delivered |
| C | tester is owner, `channels.telegram.execApprovals.enabled: false` | fallback message with change summary, `/approve <id> allow-once\|deny`, and **Allow Once** button | **Allow Once** | message edited to "approved. Applying…", then "approved and applied" posted; Gateway config on disk has `logging.level: "info"`; final reply delivered |
Telegram Web screenshots from the same leased test account (cropped to the conversation):
| | Pending | After **Allow Once** |
|---|---|---|
| Native card (A) |  |  |
| Fallback message (C) |  |  |
With no owner configured, the owner-only `openclaw` tool isn't exposed, so no change is proposed (config unchanged). That's the existing design; DM pairing sets the first owner.
qa-channel (no native approval cards), new scenario `system-agent-chat-approval`: the agent's delegated change posts the approval in the requesting conversation. A command-authorized non-owner (`commands.allowFrom` includes them, `ownerAllowFrom` does not) sends `/approve <id> allow-once` and gets "❌ Only the owner can approve OpenClaw changes in this chat."; `logging.level` is unchanged and the approval stays pending. The owner's `/approve <id> allow-once` then resolves it; `logging.level` becomes `info`; exactly one final reply. On `origin/main` the same scenario times out waiting for the approval in the chat.
Tests: `/approve` (resolves a pending OpenClaw change canonically; never submits a canonical decision for an id owned by another kind; on a channel without approver settings, a non-owner and a revoked owner never reach the canonical decision), channel custody (without approver settings only a configured owner holds custody, and only for OpenClaw changes), `approval.resolve` (custody revoked between the request and the final write leaves the approval pending), approval publication (allowed/denied changes leave the chat outcome to the system-agent owner; expired/cancelled are published once), forwarder (requesting chat gets the prompt and outcome without `approvals.*` config; a running native card suppresses it; terminal and Webchat requests never reach the saved session chat; a change applied after the deadline reports its outcome with no false expiry; the recorded expiry is reported once), plus existing gateway approval and system-agent suites. The new owner-custody and single-publisher tests fail with the fix reverted. Shared approval and forwarder fixtures moved to sibling `*.test-support.ts` modules so the test files stay under the line cap. Wall time for the touched suites: `commands-approve` + `approval-publication` + `exec-approval-forwarder` + `system-agent-approval` + `approval` ran in 59.5s across 3 Vitest shards. `pnpm tsgo:core` clean.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* chore(deps): refresh dependencies with a seven-day cutoff
* fix(deps): preserve Teams and jsdom integration contracts
Use the Teams SDK public token and processing APIs while keeping SSO sender
checks ahead of native token operations. Remove obsolete ambient declarations
and route workarounds, and cover the SDK routing with real processing tests.
Adapt the test environment to jsdom private-field bindings, preserve file bytes
and registry cleanup, and preload it through native Node and Bun workers.
* fix(test): preserve jsdom window and fixture contracts
* fix(ci): keep typecheck cache reuse within matching inputs
* feat: show ready cloud worker pool in settings
* test: complete cloud worker cleanup fixtures
* test: wait for sidebar before plugin registration check
* test: run composed gateway scenarios in source lanes
* fix(gateway): opt in to live-authorized pool details
* test: document native TypeScript shutdown noise workaround
After mock request ownership moved to transport affinity, the mutating-tool
compaction scenario still searched prompt text for the session identifier.
The provider recorded the real context overflow, but those stale selectors
found no owning requests. A copied foreign identifier in prompt content
could also match the wrong session.
Select overflow, write, and continuation requests using their existing
request.sessionId field. Exercise the actual catalog expressions with the
owner identifier absent from prompt text and a foreign identifier copied
into it. Preserve the mutation, pruning, ordering, count, and failure checks.
No shared helper or production session contract changes.
The real scenario failed before the selector repair and passed all three
after samples: request size fell from 333954 to 119338 bytes, with exactly
one logical write, one authenticated wire success, and one compaction.
The changed catalog test passed 20 pressure runs at eight workers on two
CPUs plus a CPU contender. All 58 catalog consumers passed across the
four canonical CI shards (312 files, 4485 cases); the affected 78-file,
1062-case shard passed three times. Literal one-worker unit cost: 2.794s.
A fresh real scenario replay on main, including the guarded session
observer, passed in 21.309s. Proof runs: 35954197301 and 35957749551.
The changed checker completed all type graphs, guards and dead-code scans.
Remaining global lint was stopped in favor of scoped type-aware lint with
a rejecting negative canary; that substitute, import-cycle checks,
formatting, whitespace checks and independent P2 review passed.
Removing sessionId from the Runtime prompt intentionally stabilized the
cached prefix, but the QA mock still parsed it. Requests then shared
anonymous scenario state and terminal subagent settlement lost its
requester identity.
Observe full harness-generated ids through the existing before-run hook.
Resolve existing transport affinity by exact id, otherwise a unique
64-character prefix; reject unknown, ambiguous, or missing scoped ids.
Keep scenario state per session and expose canonical identity to the
subagent-completion scenario. Preserve affinity through QA proxies and
omit HTTP-only compatibility from the native harness catalog projection.
Update the settlement fixture for the published sessions.list contract
and align the package fixture with candidate-declared schema versions
and the existing explicit immediate-drain option.
The mock released child results when the parent's HTTP response was sent,
before the parent execution owner closed. Timestamp-prefixed all-settled
inputs also missed the fixture matcher and produced a generic response.
Correlate pending children with the exact runtime parent and use the existing
scenario wait loops to verify sessions.list reports that parent done, inactive
and not aborted. Keep the existing budgets and every direct-delivery, exact-send,
privacy and restart assertion. Recognize timestamped settlement inputs without
accepting quoted history. Migrate all mock-server and scenario callers together.
The six changed standalone test files each pass 20 full runs, with 91 focused
Gateway/QA cases and 43 HTTP sibling cases passing. Linux original profile 4
passes three times (27/27 scenarios, zero skipped), plus native Telegram and
QA-channel/empty flows. Actions proof: 35846151730. Single-worker file walls:
parser 2.47s, gate 2.48s, handoff 11.67s, routing 15.33s, surface 24.26s.
Types, scoped type-aware lint with a negative canary, formatting and P2 review
pass. Full lint declaration preparation hit ancestor-install isolation and used
the requested scoped substitute. Installed-package upgrade/rollback was not run
because no candidate tarball was configured; its migrated callbacks typecheck.
* refactor: retire pre-June import and verification compatibility
Remove pre-June task, flow, and plugin-state sidecar imports, obsolete
runtime chunks, package/installer validation exceptions, the old MCP
attachment fallback, and the April self-upgrade lane with its orphan helpers.
Leave retired data files untouched and document migration through 2026.6.1.
Preserve June-and-later contracts and September delivery recovery receipts.
Refs #156190
* docs: route legacy upgrades through 2026.9.5
* test: await Telegram fixture lifecycle events
Replace the setup stopwatch with the actual stop event or terminal run outcome. Keep cancellation assertions and outer execution bounds, and prove early terminal outcomes fail promptly.
* test(qa): repair forced restart and message inspection harnesses
* test(e2e): qualify installed execution identity persistence
Extend the packed npm onboarding owner with audit opt-in, one deterministic local turn, installed CLI inspection before and after Gateway restart, and isolated-state/privacy assertions.
Qualification from 267bb9d2: new shell-flow proof fails before the harness change at the missing opt-in. All 53 focused support tests pass; final changed shell/SQLite selection took 26.94s with one worker. Changed checks and P0-P2 autoreview pass; the full export scan was reused after brace-only lint fixes. Packed Docker proof remains a remote handoff.
* test(qa): qualify live execution and channel identity
* test(audit): qualify execution identity lifecycle gaps
* test(qa): align suppression audit with required replies
* test(qa): qualify Telegram participant identity
* test(qa): provision Telegram identity fixtures
* test(qa): qualify private-production Telegram identity
* test(e2e): preserve installed execution identity proof
* test(qa): await post-delivery memory maintenance
* fix(qa): repair qualification CI boundaries
## Summary
Unblocks the 2026.9.6 Full Release Validation (Release Checks run 35743792326, jobs 106800921622 and 106800921540): `compaction-retry-mutating-tool` failed identically in the parity (candidate) and runtime-pair (core) lanes with `Code Mode terminal continuation did not report successful completion`.
## Root cause
The scenario picked the expected terminal-continuation shape by provider variant: `openai` meant the Codex-native `Script completed\n...` text and `anthropic` meant the guest JSON `{ "status": "completed", ... }` result. The `Script completed` text only exists in the Codex harness (`extensions/codex`); the OpenClaw runtime's Code Mode always returns the guest JSON regardless of model.
Until #155614 an absent `tools.codeMode` defaulted to off, so this mock-openai lane performed the write through the direct `write` tool and the `writeWireToolName !== 'exec'` guard short-circuited the assertion. #155614 made the absent setting behave as `"auto"`, so `openai/gpt-5.5` now routes the write through guest `exec` and the never-satisfiable native branch fired. The product behaved correctly: the local repro shows the terminal continuation carrying `{"status":"completed", ..., "value":{"changed":true,"created":true,...}}`, the file with the exact expected content, and one compaction.
## Fix
- Mock OpenAI records the resolved Code Mode exec surface (`native` = freeform custom tool, `guest` = `code`-schema tool) on every request snapshot (`codeModeExecSurface`).
- The scenario keys the terminal-evidence assertion on that surface instead of the provider variant, still failing closed when neither surface is present. This is strictly stronger for the OpenClaw runtime: OpenAI-model guest runs are now actually verified for `status === "completed"` instead of being skipped.
- Catalog test pins updated to the new discriminator.
## Proof
- `node scripts/run-node.mjs qa suite --provider-mode mock-openai --parity-pack agentic --concurrency 1 --model openai/gpt-5.5 --alt-model openai/gpt-5.6-luna-alt --scenario compaction-retry-mutating-tool` on origin/main: fail (reproduced CI). With this change: pass, details `wireTool=exec ... wireSuccesses=1 compactions=1`.
- `scenario-catalog-compaction`, `scenario-catalog`, `mock-openai/server`, `agentic-parity-report` tests: 417 passed.
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(qa): bind restart checkpoints to pending waits
* fix(sessions): preserve restart recovery safety guard
* fix(qa): honor current restart tool declarations
* fix(qa): honor unsafe restart tool declarations
* fix(qa): interrupt restart checkpoints before they drain
* fix(recovery): reject restart status before final capture
* test: align hot reload publication expectations
Reuse the exact three-line prerequisite from steipete PR155428 at 172e0aec90f44445ff24a4f1ae95af20993597c2. Full-file postimage and unchanged production contract match the donor; its four failing-case and six owner-supersession controls are reused. Supplemental API-direct full-file review is P0-P2 scoped-clean. No product behavior changes or assertion weakening.
* fix(models): retire catalog resources before closing work scope
Keep request cleanup admitted after credential work settles; drain the owner after discovery registry retirement. Preserve the existing cancellation barrier and normalize the equivalent Discord fixture to landed main.
* fix(qa): sanitize Slack failure causes without lint suppressions
Repair the production lint-suppression inventory failure from three
unlisted Slack QA directives. The existing failure sanitizer now creates
safe errors containing only validated Slack codes/scopes, preserving the
messages while dropping raw SDK headers and nested causes. Reuse the
existing Slack sender and retain exact channel receipt validation.
Validation: original lint inventory test reproduced the failure; repaired
inventory 4/4 and Slack owner suite 17/17 pass (28.33s combined wall with
one worker; Slack suite 9.14s total, about 0.7s test execution). Full
check:changed and independent P0-P2 review pass. No lint baseline changes.
(cherry picked from commit 44559eb315)
* test(qa): reconcile Slack runtime fixture with native send helper
Preserve the real Slack send helper in the adapter partial mock so deadline, cancellation, receipt and cleanup coverage executes native requests. Remove only the three obsolete inventory rows after landed sanitizer commit 44559eb315 removed their directives.
---------
Co-authored-by: Jason (Json) <263060202+fuller-stack-dev@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>