-
Notifications
You must be signed in to change notification settings - Fork 0
fix(linear-slack): stop the receipt-wait from eating replies; add a fast π ack #55
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
Changes from all commits
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 |
|---|---|---|
|
|
@@ -54,8 +54,22 @@ interface SlackMessage { | |
| interface SlackClientLike { | ||
| post(channel: string, text: string): Promise<{ channel: string; ts: string }>; | ||
| reply(channel: string, threadTs: string, text: string): Promise<{ channel: string; ts: string }>; | ||
| react(channel: string, messageTs: string, emoji: string): Promise<void>; | ||
| } | ||
|
|
||
| // Reacted onto the teammate's message the instant the handler picks up the turn, | ||
| // so the channel gets an acknowledgement within seconds of box-boot instead of | ||
| // sitting silent for the minutes-long harness run. | ||
| const ACK_EMOJI = 'eyes'; | ||
|
|
||
| // How long writes wait for a writeback receipt. The relay-helpers default is 3s | ||
| // β too short for the cloud worker's round-trip, so `post()` returned `ts: ''` | ||
| // and `createIssue()` returned the draft-path fallback even when the write | ||
| // actually landed (2026-06-09: the issue was created but the reply never | ||
| // posted). A longer window keeps the handler β and the box β alive until the | ||
| // receipt arrives, so the reply both confirms and flushes before teardown. | ||
| const WRITEBACK_TIMEOUT_MS = 12_000; | ||
|
|
||
| /** | ||
| * The slice of `linearClient()` this handler uses for writes. Both calls go | ||
| * through the VFS writeback (draft β mutation β receipt) and return | ||
|
|
@@ -94,7 +108,8 @@ export default defineAgent({ | |
| ], | ||
| }, | ||
| handler: async (ctx, event) => { | ||
| await handleSlackEvent(ctx, event, slackClient(), linearClient()); | ||
| const opts = { writebackTimeoutMs: WRITEBACK_TIMEOUT_MS }; | ||
| await handleSlackEvent(ctx, event, slackClient(opts), linearClient(opts)); | ||
| }, | ||
| }); | ||
|
|
||
|
|
@@ -141,6 +156,14 @@ export async function handleSlackEvent( | |
| return; | ||
| } | ||
|
|
||
| // Acknowledge before the long harness run. Fire-and-forget: the draft is | ||
| // written synchronously (so the π is queued immediately), and we DON'T await | ||
| // the receipt β the harness starts right away while the reaction flushes in | ||
| // the background. Best-effort; never fail the turn over a reaction. | ||
| void Promise.resolve(slack.react(msg.channel, msg.ts, ACK_EMOJI)).catch((err) => | ||
| ctx.log?.('warn', 'linear-slack.ack.failed', { error: errorMessage(err) }), | ||
| ); | ||
|
|
||
| const convKey = `${msg.channel}:${msg.threadTs ?? msg.ts}`; | ||
| const history = await recallThread(ctx, convKey); | ||
|
|
||
|
|
@@ -160,13 +183,7 @@ export async function handleSlackEvent( | |
| reply = isTransientLlmError(err) | ||
| ? "I'm getting rate-limited by the model right now β give me a moment and ask again." | ||
| : 'Sorry, I hit an unexpected error working on that. Please try again.'; | ||
| // Best-effort apology β we're already in the error path, so don't let an | ||
| // undelivered apology mask the original failure; just log it loudly. | ||
| try { | ||
| await postReply(slack, msg, reply); | ||
| } catch (postErr) { | ||
| ctx.log?.('error', 'linear-slack.reply.undelivered', { error: errorMessage(postErr) }); | ||
| } | ||
| await postReply(ctx, slack, msg, reply); | ||
|
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. Although try {
await postReply(ctx, slack, msg, reply);
} catch (postErr) {
ctx.log?.('error', 'linear-slack.reply.undelivered', { error: errorMessage(postErr) });
} |
||
| return; | ||
| } | ||
|
|
||
|
|
@@ -181,7 +198,7 @@ export async function handleSlackEvent( | |
| const finalReply = [prose, ...outcomes].map((s) => s.trim()).filter(Boolean).join('\n\n') | ||
| || "I looked but don't have anything to add on that."; | ||
|
|
||
| await postReply(slack, msg, finalReply); | ||
| await postReply(ctx, slack, msg, finalReply); | ||
| await rememberTurn(ctx, convKey, 'user', text); | ||
| await rememberTurn(ctx, convKey, 'assistant', finalReply); | ||
| } | ||
|
|
@@ -225,15 +242,23 @@ async function executeLinearActions( | |
| continue; | ||
| } | ||
| const { url } = await linear.createIssue({ ...pick(action, CREATE_ISSUE_FIELDS), teamId, title }); | ||
| outcomes.push(confirm(ctx, 'create_issue', url, `β Created the issue: ${url}`)); | ||
| outcomes.push(confirm( | ||
| ctx, 'create_issue', url, | ||
| `β Created the issue: ${url}`, | ||
| 'π Submitting that issue to Linear now β it should appear on the board within a minute or two.', | ||
| )); | ||
| } else if (action.action === 'comment') { | ||
| const missing = !str(action.issueId) ? 'issueId' : !str(action.body) ? 'body' : null; | ||
| if (missing) { | ||
| outcomes.push(`β οΈ Couldn't add the comment β missing \`${missing}\`.`); | ||
| continue; | ||
| } | ||
| const { url } = await linear.comment(String(action.issueId), String(action.body)); | ||
| outcomes.push(confirm(ctx, 'comment', url, `β Added the comment: ${url}`)); | ||
| outcomes.push(confirm( | ||
| ctx, 'comment', url, | ||
| `β Added the comment: ${url}`, | ||
| 'π Posting that comment to Linear now β it should appear shortly.', | ||
| )); | ||
| } else { | ||
| outcomes.push(`β οΈ I can't do "${action.action}" yet β only creating issues and commenting.`); | ||
| } | ||
|
|
@@ -245,11 +270,16 @@ async function executeLinearActions( | |
| return outcomes; | ||
| } | ||
|
|
||
| /** A receipt url proves the mutation landed; the draft-path fallback does not. */ | ||
| function confirm(ctx: WorkforceCtx, action: string, url: string, okMessage: string): string { | ||
| /** | ||
| * A receipt url (http) proves the mutation landed and we link it. The draft-path | ||
| * fallback means the receipt didn't return inside the wait window β the write | ||
| * still flushes (creates land via the mirror within ~minutes), so we report it | ||
| * as pending rather than failed, and log it for triage. | ||
| */ | ||
| function confirm(ctx: WorkforceCtx, action: string, url: string, okMessage: string, pendingMessage: string): string { | ||
| if (/^https?:\/\//i.test(url)) return okMessage; | ||
| ctx.log?.('error', 'linear-slack.action.unconfirmed', { action, url }); | ||
| return 'β οΈ I submitted that to Linear but it never confirmed (no writeback receipt) β please double-check the board before relying on it.'; | ||
| ctx.log?.('warn', 'linear-slack.action.unconfirmed', { action, url }); | ||
| return pendingMessage; | ||
| } | ||
|
|
||
| /** Copy only the allow-listed keys whose values are present. */ | ||
|
|
@@ -262,25 +292,30 @@ function pick(source: LinearAction, keys: readonly string[]): Record<string, unk | |
| } | ||
|
|
||
| /** | ||
| * Post the reply and CONFIRM it was delivered. `slackClient` doesn't call the | ||
| * Slack API β it writes a draft into the VFS mount and polls for a writeback | ||
| * receipt. When the receipt never arrives (writeback path not mounted, mount | ||
| * degraded, box torn down before flush) it RESOLVES with an empty `ts` instead | ||
| * of throwing β relay-helpers swallows the timeout. An empty `ts` therefore | ||
| * means "Slack never got this." Throw so the runtime logs `handler.error` and | ||
| * the dropped reply is visible, instead of a silent no-op that logs | ||
| * `handler.ok` with nothing in the channel (linear-slack investigation | ||
| * 2026-06-09: a whole turn's reply was orphaned as an unflushed draft). | ||
| * Post the reply. `slackClient` doesn't call the Slack API β it writes a draft | ||
| * into the VFS mount and polls up to `WRITEBACK_TIMEOUT_MS` for a receipt, | ||
| * returning an empty `ts` if none arrives. | ||
| * | ||
| * An empty `ts` is NOT proof of a drop: the `/slack/channels` scope is mounted | ||
| * (build-time guard) and the draft still flushes at box cleanup, so a missing | ||
| * receipt usually just means the worker's round-trip outran the wait. We log it | ||
| * loudly for triage but DO NOT throw β an earlier version threw here, which | ||
| * crashed the turn and tore the box down before the draft could flush, eating | ||
| * the reply entirely (2026-06-09: issue created, channel silent). The genuine | ||
| * "nothing mounts /slack" failure is caught at build time by | ||
| * tests/persona-integration-scopes, not here. | ||
| */ | ||
| async function postReply(slack: SlackClientLike, msg: SlackMessage, text: string): Promise<void> { | ||
| async function postReply( | ||
| ctx: WorkforceCtx, | ||
| slack: SlackClientLike, | ||
| msg: SlackMessage, | ||
| text: string, | ||
| ): Promise<void> { | ||
| const result = msg.threadTs | ||
| ? await slack.reply(msg.channel, msg.threadTs, text) | ||
| : await slack.post(msg.channel, text); | ||
| if (!result?.ts) { | ||
| throw new Error( | ||
| `slack ${msg.threadTs ? 'reply' : 'post'} to ${msg.channel} was not delivered ` + | ||
| '(writeback returned no receipt β is the /slack/channels subtree mounted?)', | ||
| ); | ||
| ctx.log?.('warn', 'linear-slack.reply.no-receipt', { channel: msg.channel, threaded: Boolean(msg.threadTs) }); | ||
| } | ||
| } | ||
|
|
||
|
|
||
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.
If
slack.reactthrows synchronously (for example, if the method is missing at runtime due to a dependency mismatch, or if it performs synchronous argument validation), it will throw beforePromise.resolveor.catchcan intercept it. This would crash the entire handler turn, violating the 'never fail the turn over a reaction' goal. Wrapping the call usingPromise.resolve().then(...)ensures that both synchronous and asynchronous errors are safely caught.