-
Notifications
You must be signed in to change notification settings - Fork 5
fix(slack): drop synthetic thread_ts to avoid invalid_thread_ts #212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
823ed9d
f09ecb3
0851cd4
a109625
dc9842c
3dff52d
ed946f8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ import { basename } from "path"; | |
| import { getApp, getSlackBotToken } from "./client"; | ||
| import { hasSimpleOptions } from "@/core/runtime/helpers"; | ||
| import type { StatusStreamChunk } from "@/core/types"; | ||
| import { isSyntheticOwner } from "@/ims/shared/synthetic-owner"; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Slack IM helper module. | ||
|
|
@@ -90,11 +91,18 @@ export async function postSlackQuestion(args: { | |
| const displayPrefix = prefix ?? ""; | ||
| const questionText = `${displayPrefix}${question}`; | ||
|
|
||
| // Synthetic placeholder thread ids (`task:` / `cron-job:` / `cron:`) are | ||
| // not valid Slack `thread_ts` values; if the runtime hands us one during | ||
| // a task/cron run, fall back to posting at the top of the channel rather | ||
| // than letting Slack reject the call with `invalid_thread_ts`. | ||
| const threadIsSynthetic = isSyntheticOwner(threadId); | ||
| const threadField = threadIsSynthetic ? {} : { thread_ts: threadId }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a task/cron run emits an Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — flagging this as an explicit follow-up rather than fixing in this PR. This PR is intentionally narrow: stop posting an invalid You're right that interactive button clicks / threaded replies under the new top-level message route through The proper fix needs one of:
That's a session/state-routing change that belongs in its own PR. I'd rather land this fix now to stop the Sentry bleed and tackle the routing in a focused follow-up than block on it here. Will open a tracking issue. |
||
|
|
||
| const postPlainText = async (): Promise<string | undefined> => { | ||
| const optionText = options.length > 0 ? `\nOptions: ${options.join(" / ")}` : ""; | ||
| const result = await client.chat.postMessage({ | ||
| channel: channelId, | ||
| thread_ts: threadId, | ||
| ...threadField, | ||
| text: `${questionText}${optionText}`, | ||
| token, | ||
| }); | ||
|
|
@@ -112,7 +120,7 @@ export async function postSlackQuestion(args: { | |
| try { | ||
| const result = await client.chat.postMessage({ | ||
| channel: channelId, | ||
| thread_ts: threadId, | ||
| ...threadField, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When this synthetic branch turns a task/cron Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 1d4e434. The Full suite still green (408 pass, 1 skip). |
||
| text: questionText, | ||
| blocks: [ | ||
| { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -309,7 +309,29 @@ export async function sendMessage( | |
| const formattedText = markdownToSlack(text); | ||
| const chunks = splitForSlack(formattedText); | ||
| const workspace = slackAuthRegistry.getChannelWorkspaceName(rawChannelId) || "unknown"; | ||
| const botToken = getSlackBotTokenForProcessor(processorId) ?? getSlackBotToken(channelId, threadId); | ||
|
|
||
| // A "synthetic" thread id (`task:{id}` / `cron-job:{id}:{run}` / `cron:{id}`) | ||
| // is an internal placeholder used by Ode's task/cron schedulers before a | ||
| // real Slack `thread_ts` exists. Slack rejects these with `invalid_thread_ts` | ||
| // because they are not valid message timestamps. When the agent runtime | ||
| // emits intermediate output during a task/cron run, fall back to a | ||
| // top-level channel post (no `thread_ts`) so the message still lands in | ||
| // the channel instead of being lost + captured as a Sentry error. | ||
| const threadIsSynthetic = isSyntheticOwner(threadId); | ||
|
|
||
| // Token resolution. For real threads, prefer the registry-bound token for | ||
| // this (channel, thread) — that's the token the inbound router observed | ||
| // delivering the parent message and is the safest choice for replies. For | ||
| // synthetic placeholders the call has degenerated to a top-level channel | ||
| // post (see below); the registry has no entry for a fake `thread_ts`, so | ||
| // resolve via the channel's workspace first (mirrors `sendChannelMessage`) | ||
| // before falling back to `getSlackBotToken` to avoid the multi-workspace | ||
| // edge case where `getSlackBotToken` may return the first registered token | ||
| // instead of the one for `rawChannelId`. | ||
| const botToken = getSlackBotTokenForProcessor(processorId) | ||
| ?? (threadIsSynthetic | ||
| ? (getWorkspaceBotTokenForChannel(channelId) ?? getSlackBotToken(channelId)) | ||
| : getSlackBotToken(channelId, threadId)); | ||
|
|
||
| if (!botToken) { | ||
| log.warn("No Slack bot token available for channel", { channelId }); | ||
|
|
@@ -320,6 +342,7 @@ export async function sendMessage( | |
| workspace, | ||
| channel: channelId, | ||
| thread: threadId, | ||
| threadIsSynthetic, | ||
| botTokenLast6: tokenLast6(botToken), | ||
| text, | ||
| chunks: chunks.length, | ||
|
|
@@ -329,6 +352,7 @@ export async function sendMessage( | |
| workspace, | ||
| channel: channelId, | ||
| thread: threadId, | ||
| threadIsSynthetic, | ||
| botTokenLast6: tokenLast6(botToken), | ||
| text, | ||
| chunks: chunks.length, | ||
|
|
@@ -346,7 +370,7 @@ export async function sendMessage( | |
| try { | ||
| const result = await slackApp.client.chat.postMessage({ | ||
| channel: rawChannelId, | ||
| thread_ts: threadId, | ||
| ...(threadIsSynthetic ? {} : { thread_ts: threadId }), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 6da6431. Token resolution now mirrors The real-thread path is unchanged so existing registry bindings keep winning for in-flight replies. Suite still green (408 pass, 1 skip). |
||
| text: chunk, | ||
| token: botToken, | ||
| }); | ||
|
|
@@ -358,7 +382,15 @@ export async function sendMessage( | |
| }); | ||
| lastTs = result.ts; | ||
| if (botToken && result.ts) { | ||
| slackAuthRegistry.setThreadBotToken(rawChannelId, threadId, botToken); | ||
| // Don't bind a real bot token to a synthetic placeholder thread id — | ||
| // the real platform-assigned thread is whatever `result.ts` is for | ||
| // the first message of the run. `setMessageBotToken` covers the | ||
| // outgoing message itself; the cron/task scheduler seeds the | ||
| // session for the real thread via `seedCronChannelThreadSession` / | ||
| // `seedChannelThreadSession`. | ||
| if (!threadIsSynthetic) { | ||
| slackAuthRegistry.setThreadBotToken(rawChannelId, threadId, botToken); | ||
| } | ||
| slackAuthRegistry.setMessageBotToken(rawChannelId, result.ts, botToken); | ||
| } | ||
| } catch (err) { | ||
|
|
@@ -627,22 +659,55 @@ function createSlackAdapter(processorId?: string): IMAdapter { | |
| options: string[] | undefined, | ||
| prefix?: string | ||
| ) => { | ||
| const token = getSlackBotTokenForProcessor(processorId) ?? getSlackBotToken(channelId, threadId); | ||
| // Mirror the token-resolution logic in `sendMessage`: when the thread | ||
| // id is a synthetic placeholder (`task:` / `cron-job:` / `cron:`), the | ||
| // posted question degenerates to a top-level channel post inside | ||
| // `postSlackQuestion`, so the registry has no entry for that fake | ||
| // `thread_ts`. Resolve via the channel's workspace first to avoid the | ||
| // multi-workspace edge case where `getSlackBotToken(channelId, fakeTs)` | ||
| // can fall back to the first registered token instead of the one bound | ||
| // to `channelId`. | ||
| const threadIsSynthetic = isSyntheticOwner(threadId); | ||
| const token = getSlackBotTokenForProcessor(processorId) | ||
| ?? (threadIsSynthetic | ||
| ? (getWorkspaceBotTokenForChannel(channelId) ?? getSlackBotToken(channelId)) | ||
| : getSlackBotToken(channelId, threadId)); | ||
|
Comment on lines
+672
to
+674
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| if (!token) { | ||
| // No token -> fall through to plain-text sendMessage so the question | ||
| // still gets delivered through whatever channel/path the caller has. | ||
| const optionText = options && options.length > 0 ? `\nOptions: ${options.join(" / ")}` : ""; | ||
| return sendMessage(channelId, threadId, `${prefix ?? ""}${question}${optionText}`, processorId); | ||
| } | ||
| const { postSlackQuestion } = await import("./api"); | ||
| return postSlackQuestion({ | ||
| const questionMessageTs = await postSlackQuestion({ | ||
| channelId, | ||
| threadId, | ||
| question, | ||
| options, | ||
| prefix, | ||
| token, | ||
| }); | ||
| // The synthetic branch above turns the question into a top-level channel | ||
| // post. The handler that processes the user's button click / threaded | ||
| // reply later looks up the bot token via BOTH | ||
| // getMessageBotToken(channel, selectionMessageTs) → this is the ts | ||
| // of the user's selection reply (not the question), created inside | ||
| // `commands.ts` via `chat.postMessage`, so no registry entry exists. | ||
| // getThreadBotToken(channel, threadId) → for a top-level | ||
| // question, Slack derives `threadId` from | ||
| // `body.message?.thread_ts || body.message?.ts`, which resolves to | ||
| // the question message's own ts. | ||
| // Register the resolved `token` under both the message-token map (for | ||
| // parity with sendMessage's outgoing binding) AND the thread-token map | ||
| // keyed by the question ts, so the button-selection path can recover | ||
| // the correct workspace token in multi-workspace installs where the | ||
| // channel has not yet been bound by the message router (Codex P2 on | ||
| // PR #212). | ||
| if (questionMessageTs) { | ||
| slackAuthRegistry.setMessageBotToken(channelId, questionMessageTs, token); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In a multi-workspace install where a task/cron question is posted as a new top-level Slack message and the user clicks a button, this new binding still won't be found by the button path: Useful? React with 👍 / 👎. |
||
| slackAuthRegistry.setThreadBotToken(channelId, questionMessageTs, token); | ||
| } | ||
| return questionMessageTs; | ||
| }, | ||
| updateMessage: (channelId: string, messageTs: string, text: string) => | ||
| updateMessage(channelId, messageTs, text, processorId), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a synthetic task/cron question is mirrored to the real Slack question thread, this spread also copies the synthetic session's
activeRequestinto the real-thread session. The active request lifecycle still completes/fails only the original synthetic key (for examplecompleteActiveRequest(context.channelId, context.threadId)inrequest-run.ts), so after the user answers and the run finishes the real-thread copy remainsstate: "processing". If the daemon restarts or the user sendsstopin that real thread, recovery/stop handling will treat the already-finished run as pending and update/delete the stale status message or abort the session; the mirror should omitactiveRequestand only seed the routing/session fields pluspendingQuestion.Useful? React with 👍 / 👎.