openclaw - 💡(How to fix) Fix completionAnnouncedAt set on queue-accept, not transcript commit — false 'announced' state during collect-mode queue drain

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

completionAnnouncedAt is set when the subagent announce queue accepts an item, not when the queue drain actually injects the announce into the parent transcript. On busy parents using messages.queue.mode = "collect" with non-trivial debounceMs, the runs registry / dashboards report a subagent as "announced" 5–30 seconds before the parent session actually receives the completion message.

Root Cause

In dist/subagent-announce-delivery-*.js, mapQueueOutcomeToDeliveryResult returns delivered: true as soon as the queue accepts an item:

function mapQueueOutcomeToDeliveryResult(outcome) {
    if (outcome === "steered") return { delivered: true, path: "steered" };
    if (outcome === "queued")  return { delivered: true, path: "queued"  };
    return { delivered: false, path: "none" };
}

maybeQueueSubagentAnnounce returns "queued" immediately after enqueueAnnounce puts the item in the queue (enqueueAnnounce returns true after queue.items.push(...), no awaiting of the drain). The actual transcript injection happens asynchronously inside scheduleAnnounceDrainqueue.send(item)sendAnnounce(item)subagentAnnounceDeliveryDeps.callGateway({ method: "agent", ... }). That async commit is never signalled back to the registry, so the registry treats the queue-accept as the announce having succeeded.

subagent-registry-*.js then runs:

const finalizeSubagentCleanup = async (runId, cleanup, didAnnounce, options) => {
    const entry = params.runs.get(runId);
    if (!entry) return;
    if (didAnnounce) {
        if (!options?.skipAnnounce) {
            entry.completionAnnouncedAt = Date.now();
            params.persist();
        }
    }
    // ...
};

So completionAnnouncedAt is whatever Date.now() was when the queue accepted the item, not when the parent's transcript injection committed.

Fix Action

Fix / Workaround

Earlier runs (before our parent moved to collect + debounce) succeeded because the dispatcher took the direct path; that path synchronously commits to the transcript inside runSubagentAnnounceDispatch.params.direct() and only marks delivered: true once the gateway call resolves. The bug is specific to the queue (and steered-queue) path.

Workaround we're shipping locally

We've applied a local observability-only patch (patch-announce-completion-timestamp.sh) that:

Code Example

"messages": { "queue": { "mode": "collect", "debounceMs": 2500 } }

---

function mapQueueOutcomeToDeliveryResult(outcome) {
    if (outcome === "steered") return { delivered: true, path: "steered" };
    if (outcome === "queued")  return { delivered: true, path: "queued"  };
    return { delivered: false, path: "none" };
}

---

const finalizeSubagentCleanup = async (runId, cleanup, didAnnounce, options) => {
    const entry = params.runs.get(runId);
    if (!entry) return;
    if (didAnnounce) {
        if (!options?.skipAnnounce) {
            entry.completionAnnouncedAt = Date.now();
            params.persist();
        }
    }
    // ...
};
RAW_BUFFERClick to expand / collapse

Summary

completionAnnouncedAt is set when the subagent announce queue accepts an item, not when the queue drain actually injects the announce into the parent transcript. On busy parents using messages.queue.mode = "collect" with non-trivial debounceMs, the runs registry / dashboards report a subagent as "announced" 5–30 seconds before the parent session actually receives the completion message.

Symptom

For a Pierre-style CLI parent with:

"messages": { "queue": { "mode": "collect", "debounceMs": 2500 } }

I observed a subagent run that ended at 2026-05-17T04:21:39.923Z with completionAnnouncedAt written 985 ms later. The transcript user-message carrying the announce did not appear in the parent JSONL until 2026-05-17T04:22:07.354Z27.4 s after child end — and arrived under the [Queued announce messages while agent was busy] header that the collect-queue drain emits. Tooling that watches runs.json therefore declares the run delivered ~26 s before the parent session can see it, which trips downstream "did the child actually deliver?" assertions and breaks human "where's my Signal ping?" expectations.

Earlier runs (before our parent moved to collect + debounce) succeeded because the dispatcher took the direct path; that path synchronously commits to the transcript inside runSubagentAnnounceDispatch.params.direct() and only marks delivered: true once the gateway call resolves. The bug is specific to the queue (and steered-queue) path.

Root cause

In dist/subagent-announce-delivery-*.js, mapQueueOutcomeToDeliveryResult returns delivered: true as soon as the queue accepts an item:

function mapQueueOutcomeToDeliveryResult(outcome) {
    if (outcome === "steered") return { delivered: true, path: "steered" };
    if (outcome === "queued")  return { delivered: true, path: "queued"  };
    return { delivered: false, path: "none" };
}

maybeQueueSubagentAnnounce returns "queued" immediately after enqueueAnnounce puts the item in the queue (enqueueAnnounce returns true after queue.items.push(...), no awaiting of the drain). The actual transcript injection happens asynchronously inside scheduleAnnounceDrainqueue.send(item)sendAnnounce(item)subagentAnnounceDeliveryDeps.callGateway({ method: "agent", ... }). That async commit is never signalled back to the registry, so the registry treats the queue-accept as the announce having succeeded.

subagent-registry-*.js then runs:

const finalizeSubagentCleanup = async (runId, cleanup, didAnnounce, options) => {
    const entry = params.runs.get(runId);
    if (!entry) return;
    if (didAnnounce) {
        if (!options?.skipAnnounce) {
            entry.completionAnnouncedAt = Date.now();
            params.persist();
        }
    }
    // ...
};

So completionAnnouncedAt is whatever Date.now() was when the queue accepted the item, not when the parent's transcript injection committed.

Reproduction

  1. Set parent's messages.queue.mode = "collect" with debounceMs >= 2000.
  2. Hold the parent in an active turn (any in-flight tool call works).
  3. Spawn a quick subagent that ends while the parent is busy.
  4. Compare:
    • runs.json <runId>.completionAnnouncedAt
    • The first t: timestamp in ~/.openclaw/agents/<agent>/sessions/<parentSession>.jsonl whose body contains [Queued announce messages while agent was busy] for that child.

You'll see the announce timestamp debounceMs + queue-drain-lag earlier than the transcript commit, with the gap scaling roughly with debounceMs.

Proposed fix

Decouple "queue accepted" from "transcript committed":

  1. New field completionEnqueuedAt (queue/steered path only) — set when mapQueueOutcomeToDeliveryResult returns the queue/steered outcome.
  2. Defer completionAnnouncedAt until the actual transcript commit. The queue drain's queue.send(item) call (today, sendAnnounce) is where the gateway commit happens. Plumb a callback (or hand the registry a markAnnouncedAt keyed by announceId or runId) so the drain can notify the registry once callGateway({ method: "agent", ... }) resolves.
  3. Optional completionDeliveredAt alongside completionAnnouncedAt so future readers can tell the difference between "announce flow finished" and "transcript has the message". Keep idempotency via the existing announce:${announceId} key.

Backward compatibility: existing readers of completionAnnouncedAt still work — they just observe the (correctly later) commit timestamp instead of the queue-accept timestamp.

Workaround we're shipping locally

We've applied a local observability-only patch (patch-announce-completion-timestamp.sh) that:

  • Adds enqueuedAt: Date.now() to the queued / steered branches of mapQueueOutcomeToDeliveryResult.
  • Captures delivery.enqueuedAt on the registry entry as completionEnqueuedAt inside the existing onDeliveryResult callback.

We deliberately did not change the semantics of completionAnnouncedAt or the delivered: true return — the full decoupling needs the API change described in (2) above.

Why this matters

This isn't just a dashboard cosmetic. The premature completionAnnouncedAt is what made our human user think the subagent silently dropped its result ("you stopped responding"). The wrapper / Signal layer correctly believed the announce had been delivered and stopped retrying, while the parent session was still 25+ seconds away from seeing the message.

Environment

  • OpenClaw bundled npm: 2026.5.12
  • Node: v24.15.0
  • Parent session model: claude-cli/claude-opus-4-7 (Claude Code harness)
  • messages.queue.mode = "collect", debounceMs = 2500
  • Workaround patch applied locally; happy to test any fix against the same reproduction.

Files referenced (line numbers from current bundled dist):

  • dist/subagent-announce-delivery-DzsdC5tX.js:173-186 (mapQueueOutcomeToDeliveryResult)
  • dist/subagent-announce-delivery-DzsdC5tX.js:559-598 (maybeQueueSubagentAnnounce)
  • dist/subagent-announce-delivery-DzsdC5tX.js:501-538 (sendAnnounce)
  • dist/subagent-announce-queue-Dz5J_UzW.js:77-158 (scheduleAnnounceDrain — the real commit site)
  • dist/subagent-registry-32aElbRE.js:640-647 (finalizeSubagentCleanup)
  • dist/subagent-registry-32aElbRE.js:799-813 (onDeliveryResult callback)

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - 💡(How to fix) Fix completionAnnouncedAt set on queue-accept, not transcript commit — false 'announced' state during collect-mode queue drain