Skip to content

fix(linear-slack): deliver Slack replies + guard the writeback-scope footgun#53

Merged
khaliqgant merged 1 commit into
mainfrom
fix/linear-slack-writeback-scope-guard
Jun 9, 2026
Merged

fix(linear-slack): deliver Slack replies + guard the writeback-scope footgun#53
khaliqgant merged 1 commit into
mainfrom
fix/linear-slack-writeback-scope-guard

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Jun 9, 2026

Copy link
Copy Markdown
Member

What happened

linear-slack was engaged in Slack and silently dropped its reply — nothing appeared in the channel, yet the run logged handler.ok. Traced live via the Daytona CLI into the running sandbox:

  • The trigger fired, the harness ran (~3m17s) and produced a reply.
  • slackClient().post() doesn't call the Slack API — it writes a draft into the VFS mount and polls for a writeback receipt. The reply draft was recovered, orphaned and undelivered, at /slack/channels/C0B9287EP6Y/messages/messages <uuid>.json.

Root cause: the labelled-mirror footgun

  • The persona used scope-less slack: {}. The only Slack mount was the trigger's read-only mirror of the display-labelled channel path /slack/channels/{id}__{name}/messages.
  • But the writeback path is canonical bare-id /slack/channels/{id}/messages. They never coincide, so the draft landed on unmounted disk and the writeback worker never flushed it.
  • vfs-client.writeJsonFile returns receipt: undefined on timeout and slackClient.post returns { ts: '' } without throwing → the runtime logged handler.ok with nothing delivered.

(Bonus finding: the "AR-83" issue the agent claimed to create was also fake — it wrote local JSON into the read-only Linear mirror, not a real Linear mutation. Out of scope for this PR.)

Changes

  1. persona.ts — fix delivery. Scope slack to /slack/channels/** so the writeback path is actually mounted. Same fix already shipped in review/vendor-monitor/hn-monitor/repo-hygiene; linear-slack was the last holdout.
  2. agent.ts — make failures loud. postReply now treats an empty receipt ts as a delivery failure and throws, so a dropped reply surfaces as handler.error instead of a false handler.ok.
  3. tests/persona-integration-scopes.test.mjs — tighten the guard. The existing guard treated trigger OR scope as sufficient, which false-negatived this case (linear-slack has a slack trigger). New sub-rule: a write to a display-labelled provider (WRITEBACK_NEEDS_SCOPE = {slack}) requires a scope even when a trigger exists. github/linear are unaffected — their trigger and writeback paths share one bare-id form. Proven red→green against the original config.

This PR also adds the linear-slack agent itself (it was deployed but never committed). It ships only agent.ts + persona.ts; README/graphics to follow if we want it in the showcase set.

Preventing this for users

Repo-local guards/docs only protect this repo. Framework-level prevention is tracked in AgentWorkforce/cloud#2029 — chiefly: make slackClient write methods reject on a missing receipt (so every future occurrence is a visible error, not a silent no-op), plus auto-mounting writeback paths and a persona-kit compile-time check. The writing-agent-personas skill §1 is updated with the labelled-mirror sub-trap (separate PR in AgentWorkforce/skills).

Test

npm run typecheck clean; npm test 54/54, including the tightened guard proven red on the broken config and green on the fix.

🤖 Generated with Claude Code


Summary by cubic

Fixes undelivered Slack replies in linear-slack by mounting the writeback path and failing fast on missing delivery receipts. Also adds the agent to the repo and tightens the scope guard to prevent future silent Slack writebacks.

  • Bug Fixes

    • Scoped Slack to /slack/channels/** so writebacks use a mounted path and replies deliver.
    • postReply now throws when Slack returns an empty ts, surfacing dropped replies as handler.error.
    • Tightened integration-scope guard: Slack writes require a non-empty scope even with a trigger; prevents drafts landing on unmounted disk. GitHub/Linear unaffected.
  • New Features

    • Added the linear-slack agent and persona (agent.ts, persona.ts).

Written for commit cc1b2fc. Summary will update on new commits.

Review in cubic

…footgun

linear-slack posted replies into the void. Scope-less `slack: {}` meant the
only Slack mount was the trigger's READ mirror of the display-labelled channel
(/slack/channels/{id}__{name}/...), but slackClient().post() writes to the
canonical bare-id writeback path (/slack/channels/{id}/messages). The draft
landed on unmounted disk and was never flushed — confirmed by recovering the
orphaned draft from the live sandbox. slackClient swallows the missing-receipt
timeout (returns ts:'' without throwing), so the runtime logged handler.ok with
nothing in the channel.

- persona.ts: scope slack to /slack/channels/** so the writeback path mounts
  (same fix already shipped in review/vendor-monitor/hn-monitor/repo-hygiene).
- agent.ts: postReply treats an empty receipt ts as a delivery failure and
  throws, so a dropped reply surfaces as handler.error instead of a silent
  no-op.
- tests: tighten the integration-scope guard — a Slack WRITE now requires a
  scope even when a trigger exists. The old trigger-or-scope rule
  false-negatived this exact case; github/linear are unaffected (their trigger
  and writeback paths share one bare-id form). Proven red→green.

Refs AgentWorkforce/cloud#2029 (framework-level prevention).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codeant-ai

codeant-ai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Your free trial PR review limit of 300 PRs has been reached. Please upgrade your plan to continue using CodeAnt AI.

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e437a2d3-594f-4ca8-8349-6ce75f535f47

📥 Commits

Reviewing files that changed from the base of the PR and between d6f53a4 and cc1b2fc.

📒 Files selected for processing (3)
  • linear-slack/agent.ts
  • linear-slack/persona.ts
  • tests/persona-integration-scopes.test.mjs

📝 Walkthrough

Walkthrough

Introduces the linear-slack agent persona that processes Slack messages in a configured channel, reconstructs conversation history from memory, invokes Claude with mounted Linear VFS access, and persists bidirectional message exchanges. Tightens test infrastructure to enforce scope requirements for providers with display-labeled triggers that cannot cover bare-id writeback paths.

Changes

Linear-Slack Agent Feature

Layer / File(s) Summary
Persona configuration and harness setup
linear-slack/persona.ts
Defines the linear-slack persona with Claude harness in sandbox, configures Slack and Linear cloud integrations with scoped mount paths, exposes SLACK_CHANNEL deploy-time input, and provides system prompt and harness settings.
Slack event handler and message orchestration
linear-slack/agent.ts
Registers the agent trigger, implements the main event handler that validates Slack messages, reconstructs thread history from memory, invokes the harness, handles transient vs. permanent LLM failures with user-facing messages, and persists both user and assistant turns to memory.
Message parsing and utility helpers
linear-slack/agent.ts
Provides Slack payload normalization and parsing, user mention stripping, transient LLM error classification, and consistent logging helpers for skip decisions.
Test infrastructure: write detection and scope enforcement
tests/persona-integration-scopes.test.mjs
Tightens provider mount invariants in persona integration tests by introducing write-detection logic that identifies actual provider writes via named clients, generic write methods, and raw VFS calls, then enforces non-empty scope requirement for WRITEBACK_NEEDS_SCOPE providers (slack) even when a trigger exists.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

size:L

Poem

🐰 A rabbit hops through Slack's grand hall,
Whispers Linear's tales to all,
With memory threads both old and new,
Claude replies with wisdom true.
Now scopes enforce what must be writ—
A perfect integration fit! 🌙✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the two main changes: fixing silent Slack reply delivery failures and adding a guard against the writeback-scope footgun.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, clearly explaining the root cause, all changes made, and testing approach.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/linear-slack-writeback-scope-guard

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a Slack-native conversational Linear board assistant (linear-slack) that navigates a mounted Linear VFS inside a sandbox to read and update issues. It also updates integration scope tests to enforce that display-labelled providers like Slack require an explicit scope for write operations. Key feedback includes: refining Slack message subtype filtering to avoid ignoring valid human messages (e.g., file shares); rethrowing harness errors so failures are not hidden from monitoring; ensuring replies are always threaded to preserve multi-turn conversation history; and correcting the invalid model identifier claude-sonnet-4-6 to a valid one like claude-3-5-sonnet.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread linear-slack/agent.ts
Comment on lines +83 to +87
// Only fresh human messages — skip edits/deletes/joins/etc.
if (msg.subtype) {
logSkip(ctx, event, `slack subtype ${msg.subtype}`);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Skipping all messages with any subtype will cause the agent to silently ignore valid human messages that have subtypes, such as file_share (when a user uploads an image/file with a comment) or thread_broadcast (when a user cross-posts a thread reply to the channel). Instead of skipping all subtypes, consider skipping only specific system or edit subtypes (e.g., message_changed, message_deleted, channel_join, channel_leave, tombstone).

Suggested change
// Only fresh human messages — skip edits/deletes/joins/etc.
if (msg.subtype) {
logSkip(ctx, event, `slack subtype ${msg.subtype}`);
return;
}
// Only fresh human messages — skip edits/deletes/joins/etc.
const ignoredSubtypes = new Set(['message_changed', 'message_deleted', 'channel_join', 'channel_leave', 'tombstone']);
if (msg.subtype && ignoredSubtypes.has(msg.subtype)) {
logSkip(ctx, event, 'slack subtype ' + msg.subtype);
return;
}

Comment thread linear-slack/agent.ts
Comment on lines +118 to +131
} catch (err) {
ctx.log?.('warn', 'linear-slack.harness.failed', { error: errorMessage(err) });
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) });
}
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When the harness fails, the agent catches the error, sends a best-effort apology, and returns successfully. This causes the platform to log handler.ok instead of handler.error, hiding harness failures from automated monitoring and alerting. Rethrow the original error after attempting to send the apology so that failures are loud and visible.

Suggested change
} catch (err) {
ctx.log?.('warn', 'linear-slack.harness.failed', { error: errorMessage(err) });
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) });
}
return;
}
} catch (err) {
ctx.log?.('warn', 'linear-slack.harness.failed', { error: errorMessage(err) });
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) });
}
throw err;
}

Comment thread linear-slack/agent.ts
Comment on lines +150 to +153
const result = msg.threadTs
? await slack.reply(msg.channel, msg.threadTs, text)
: await slack.post(msg.channel, text);
if (!result?.ts) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the user sends a top-level message (msg.threadTs is undefined), the bot currently posts its reply as a new top-level message using slack.post. Because the reply is not threaded, any subsequent user messages will also be top-level, resulting in a new ts and a different convKey (${msg.channel}:${msg.ts}). This completely breaks multi-turn conversation history. To preserve history, the bot should always reply in a thread by falling back to msg.ts when msg.threadTs is undefined.

async function postReply(slack: SlackClientLike, msg: SlackMessage, text: string): Promise<void> {
  const result = await slack.reply(msg.channel, msg.threadTs ?? msg.ts, text);

Comment thread linear-slack/persona.ts
},

harness: 'claude',
model: 'claude-sonnet-4-6',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The model name claude-sonnet-4-6 is invalid or non-existent (the current latest is Claude 3.5 Sonnet or Claude 3.7 Sonnet). This will cause model resolution failures at runtime. Use a valid model identifier such as claude-3-5-sonnet.

Suggested change
model: 'claude-sonnet-4-6',
model: 'claude-3-5-sonnet',

@agent-relay-code

Copy link
Copy Markdown
Contributor

pr-reviewer could not complete review for #53 in AgentWorkforce/agents.
The review harness exited with code 1.
No review was posted; this needs operator attention.

@agent-relay-code

Copy link
Copy Markdown
Contributor

ℹ️ pr-reviewer: review only — no file changes were applied to the PR (nothing to commit after review). The notes below are advisory and were not pushed.

pr-reviewer could not complete review for #53 in AgentWorkforce/agents.
The review harness exited with code 1.
No review was posted; this needs operator attention.

@agent-relay-code

Copy link
Copy Markdown
Contributor

pr-reviewer could not complete review for #53 in AgentWorkforce/agents.
The review harness exited with code 1.
No review was posted; this needs operator attention.

@agent-relay-code

Copy link
Copy Markdown
Contributor

ℹ️ pr-reviewer: review only — no file changes were applied to the PR (nothing to commit after review). The notes below are advisory and were not pushed.

pr-reviewer could not complete review for #53 in AgentWorkforce/agents.
The review harness exited with code 1.
No review was posted; this needs operator attention.

@agent-relay-code

Copy link
Copy Markdown
Contributor

pr-reviewer could not complete review for #53 in AgentWorkforce/agents.
The review harness exited with code 1.
No review was posted; this needs operator attention.

@agent-relay-code

Copy link
Copy Markdown
Contributor

ℹ️ pr-reviewer: review only — no file changes were applied to the PR (nothing to commit after review). The notes below are advisory and were not pushed.

pr-reviewer could not complete review for #53 in AgentWorkforce/agents.
The review harness exited with code 1.
No review was posted; this needs operator attention.

khaliqgant added a commit to AgentWorkforce/skills that referenced this pull request Jun 9, 2026
…olidated skill

A Slack trigger mirrors the display-labelled path read-only and never covers the
bare-id writeback path, so a Slack WRITE always needs a scope — a trigger is not
enough. Surfaced by the linear-slack silent-drop bug (2026-06; orphaned draft
recovered from the live sandbox).

- starter persona.json: scope slack to /slack/channels/** instead of "slack": {}
  (the example previously shipped the footgun and contradicted the real review
  agent it models).
- scope warning: rewrite — a Slack trigger does NOT cover a Slack write; explain
  the labelled-mirror vs bare-id path mismatch.
- §1: add the labelled-mirror sub-trap, correct the "trigger or scope" rule to
  carve out Slack, and add the make-delivery-loud rule (empty ts ⇒ throw).

Refs AgentWorkforce/agents#53, AgentWorkforce/cloud#2029.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@khaliqgant khaliqgant merged commit 82aff2a into main Jun 9, 2026
2 checks passed
@khaliqgant khaliqgant deleted the fix/linear-slack-writeback-scope-guard branch June 9, 2026 12:20
khaliqgant added a commit to AgentWorkforce/skills that referenced this pull request Jun 9, 2026
* Document and vendor creating cloud persona skill refs

* chore: apply pr-reviewer fixes for #73

* chore: apply pr-reviewer fixes for #73

* chore: apply pr-reviewer fixes for #73

* chore: apply pr-reviewer fixes for #73

* chore: apply pr-reviewer fixes for #73

* docs(creating-cloud-persona): fix the Slack-write footgun in the consolidated skill

A Slack trigger mirrors the display-labelled path read-only and never covers the
bare-id writeback path, so a Slack WRITE always needs a scope — a trigger is not
enough. Surfaced by the linear-slack silent-drop bug (2026-06; orphaned draft
recovered from the live sandbox).

- starter persona.json: scope slack to /slack/channels/** instead of "slack": {}
  (the example previously shipped the footgun and contradicted the real review
  agent it models).
- scope warning: rewrite — a Slack trigger does NOT cover a Slack write; explain
  the labelled-mirror vs bare-id path mismatch.
- §1: add the labelled-mirror sub-trap, correct the "trigger or scope" rule to
  carve out Slack, and add the make-delivery-loud rule (empty ts ⇒ throw).

Refs AgentWorkforce/agents#53, AgentWorkforce/cloud#2029.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: agent-relay-code[bot] <agent-relay-code[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@agent-relay-code

Copy link
Copy Markdown
Contributor

pr-reviewer could not complete review for #53 in AgentWorkforce/agents.
The review harness exited with code 1.
No review was posted; this needs operator attention.

@agent-relay-code

Copy link
Copy Markdown
Contributor

ℹ️ pr-reviewer: review only — no file changes were applied to the PR (nothing to commit after review). The notes below are advisory and were not pushed.

pr-reviewer could not complete review for #53 in AgentWorkforce/agents.
The review harness exited with code 1.
No review was posted; this needs operator attention.

1 similar comment
@agent-relay-code

Copy link
Copy Markdown
Contributor

ℹ️ pr-reviewer: review only — no file changes were applied to the PR (nothing to commit after review). The notes below are advisory and were not pushed.

pr-reviewer could not complete review for #53 in AgentWorkforce/agents.
The review harness exited with code 1.
No review was posted; this needs operator attention.

@agent-relay-code

Copy link
Copy Markdown
Contributor

pr-reviewer could not complete review for #53 in AgentWorkforce/agents.
The review harness exited with code 1.
No review was posted; this needs operator attention.

@agent-relay-code

Copy link
Copy Markdown
Contributor

ℹ️ pr-reviewer: review only — no file changes were applied to the PR (nothing to commit after review). The notes below are advisory and were not pushed.

pr-reviewer could not complete review for #53 in AgentWorkforce/agents.
The review harness exited with code 1.
No review was posted; this needs operator attention.

khaliqgant added a commit that referenced this pull request Jun 9, 2026
…em) (#54)

Asked to "make an issue", the harness hand-wrote a JSON file into the read-only
Linear mirror — inventing an AR-NN ref + UUID and copying an existing issue's
shape (including read-only fields the writeback rejects). That is not a Linear
mutation: nothing was created, yet the reply claimed "Done! Created AR-83".

Route board WRITES through the real writeback instead of the harness filesystem:

- persona.ts: the harness treats ./linear as READ-ONLY and, when a change is
  asked for, resolves real ids from the VFS and emits a fenced `linear-actions`
  block (create_issue / comment). It must not invent ids/refs, must not claim
  success in prose, and is told a milestone can't be set on create.
- agent.ts: the handler parses that block and executes each action through
  `linearClient()` (draft → issueCreate/comment → receipt), allow-listing
  IssueCreateInput fields so a stray read-only field can't fail the create. It
  reports the CONFIRMED Linear url; because relay-helpers `created()` falls back
  to the draft path on a missing receipt, a non-http url is surfaced as
  "unconfirmed" rather than claimed done. Malformed/again-missing ids are
  refused, not guessed.
- tests: new tests/linear-slack-agent.test.mjs — confirmed create, unconfirmed
  (no-receipt) create, missing-id refusal, comment, read-only turn, malformed
  block.

Follow-up to #53 (which fixed delivery). Refs AgentWorkforce/cloud#2029.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
khaliqgant added a commit that referenced this pull request Jun 9, 2026
…ast ack (#55)

Live trace of a real run: the issue WAS created (AR-87 in Launch SDK) but no
reply ever posted. Two causes, one of them self-inflicted by #53:

- The writeback receipt timeout defaults to 3s — too short for the cloud
  worker's round-trip. `slack.post()` returned `ts:''` and `createIssue()`
  returned the draft-path fallback even though both writes landed.
- #53 made `postReply` THROW on an empty `ts`. With the scope mounted the draft
  still flushes at cleanup, so an empty `ts` is a slow receipt, not a drop — but
  the throw crashed the turn and tore the box down before the reply flushed.
  Issue created, channel silent.

Fixes:
- Build slack/linear clients with `writebackTimeoutMs: 12s` so receipts have
  time to arrive (keeps the box alive to flush) and we get real confirmation.
- `postReply` no longer throws on a missing receipt — it logs
  `linear-slack.reply.no-receipt` and lets the draft flush. The genuine
  no-scope failure is already caught at build time by
  tests/persona-integration-scopes, not at runtime.
- Reframe an unconfirmed create/comment from a scary "never confirmed" to
  "submitting now — appears on the board shortly" (creates land via the mirror
  within ~minutes), still logged for triage.
- Add a fast 👀 ack: react on the teammate's message the moment the handler
  picks up the turn (fire-and-forget, doesn't block the harness), so the channel
  isn't silent for the minutes-long run.

Follow-up to #53/#54. Refs AgentWorkforce/cloud#2029.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant