Skip to content

slack-bot: activity callback can leave a completed thread showing "Working…" #1898

Description

@rhlsthrm

Summary

The activity callback added in #1867 acknowledges before its Slack status write happens, so a session that completes in the same window can be left showing Working… permanently.

Mechanism

In packages/slack-bot/src/callbacks.ts:

c.executionCtx.waitUntil(
  setAssistantThreadStatusBestEffort(
    c.env,
    valid.context.channel,
    valid.context.threadTs,
    ASSISTANT_WORKING_STATUS,
    { event: "refresh", traceId, sessionId: valid.sessionId }
  )
);

The handler then logs and returns 200 without awaiting that task. So the ordering between the deferred Working… write and the completion path is unconstrained across workers:

  1. sandbox heartbeat → POST /callbacks/activity;
  2. handler schedules the status write via waitUntil and returns 200;
  3. the completion queue delivers the final reply and clears the thread status;
  4. the deferred task from step 2 now runs and re-asserts ASSISTANT_WORKING_STATUS.

After step 4 nothing clears it again — the completion path has already run, and the next clear would only come from another session on the same thread. The control-plane's check before sending the callback cannot close this, because the window opens after the callback is acknowledged.

The stale-timestamp guard above it (ACTIVITY_CALLBACK_MAX_AGE_MS) bounds how old the callback may be, not how long the deferred write may be queued, so it does not constrain this ordering either.

Why it is worth fixing rather than tolerating

setAssistantThreadStatusBestEffort is deliberately best-effort, which is right for a transient indicator — but the failure here is not a dropped update, it is a durable wrong state on a finished thread. The user sees an assistant that appears to still be working with no further output.

Possible directions

Both involve a tradeoff I do not think an outside contributor should pick unilaterally, since it belongs to the heartbeat contract introduced in #1867:

  1. Await the status write before acknowledging. Simple and removes the window, but costs callback ack latency — presumably the reason waitUntil was chosen.
  2. Re-check liveness inside the deferred task, immediately before writing, and skip the write if the session is no longer active. Keeps the fast ack; needs a cheap authoritative liveness read and still leaves a much smaller window unless the status write and the check are ordered against the completion path.

A third option is to make the completion path authoritative by having it record a monotonic marker the activity write compares against, so a late Working… write cannot overwrite a newer completion — analogous to the status-revision approach used for session archiving.

Provenance

Found while reviewing an upstream sync into a downstream fork; the file is unmodified from upstream, so this is an upstream report rather than a fork-local divergence. I have not shipped a fix.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions