Skip to content

feat(database,features,service): an Inbox item holds a thread of messages - #3469

Merged
luke-agent[bot] merged 3 commits into
mainfrom
luke/inbox-thread-messages
Oct 17, 2026
Merged

luke-agent[bot] merged 3 commits into
mainfrom
luke/inbox-thread-messages

Conversation

@dastratakos

@dastratakos dastratakos commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The ask: Dean wants to be able to add a message to an Inbox item's thread at any point, not only the one answer an item takes today. This is the first of two PRs, the service half. The second PR adds the agent's read_inbox thread and a reply_inbox tool. A separate desktop worker builds the window on top of this one.

  • Table: inbox_message holds the thread (packages/database/src/schemas/inbox-message.ts). Each row has:
    • the item, cascading on delete;
    • the author, member with their user_id (set null when the account goes) or agent, the item's own;
    • the words as Markdown, kept verbatim as an answer's note is, so mentions survive;
    • an optional client_key, unique per item;
    • created_at.
  • Files: a message's files reuse inbox_attachment with a new message kind and a message_id link. They have the same bounds as an answer's file: the PDF bound, plus a name and a position. The kind check now pins message_id to the message kind alone.
  • Wire (inbox-wire.ts): an item read carries messages, oldest first, the latest MAX_MESSAGES_READ (50) of them. Each message is { id, author: "member" | "agent", member?: { userId, name }, text, files?: [{ attachmentId, name, mediaType }], createdAt }. A file is fetched through the existing attachment path. messages is absent where the thread is empty.
  • Route: POST /api/inbox/{id}/messages takes { text, attachments?, clientKey }, with words or files or both, and answers { item }.
    • Who may post: any member of the item's workspace, on any item they could answer, open, answered, or closed.
    • What it changes: it writes the message and its files, and moves updated_at and nothing else. status, answer, and revision stand.
    • How it reaches the agent: through the same steer as a tab message, under INBOX_MESSAGE_LEAD ("Inbox message on "<title>" (key ):") with its files. This opens a turn on an agent that stopped, as a tab message does.
    • Failures and repeats: a hand-off that fails takes the message back off the thread, as a failed answer is taken back. A repeat under the same clientKey is written once and handed off again, and the steer's key makes it reach the agent once. An agent whose session has ended for good keeps the message.
  • settled_at: inbox_item gains settled_at, written when an item is answered or closed and cleared when a post or a withdrawn answer reopens it. It reaches the wire as an optional settledAt, so the desktop no longer reads updatedAt, which a message now moves, as the time an item settled.
    • There is no backfill. A read takes coalesce(settled_at, updated_at) for a settled item, and the first message on an old row copies its updated_at into settled_at before moving it, so every row reads correctly.
  • A reused key is refused: a clientKey that already names a different message (another member, other words, or other files) is refused as invalid-request. Only the send that wrote a message takes it back when its hand-off fails.
  • Client: HostedCodingAgentClient.messageInbox(itemId, request) and codingAgentInboxMessagedViewSchema, for the desktop's Gateway. Path builder: inboxMessagesPath.
  • A post under the item's key keeps the thread: postInboxItem's attachment sweep now spares the message kind.
  • Reads that watch for changes:
  • PRIVACY.md: adds one sentence saying a thread's messages and files are kept with the item, under the member who wrote them, and handed to the agent. Dean decided the feature (members post on any item, and the message reaches the agent); this sentence states it.

Migration (20261017134832_inbox_message_thread)

Online-safe and idempotent:

  • CREATE TABLE IF NOT EXISTS for inbox_message.
  • Nullable inbox_attachment.message_id and inbox_item.settled_at columns with no default, each a catalog-only change.
  • IF NOT EXISTS indexes. The inbox_attachment index covers a few rows per item, their bytes stored out of line, and its column is null on every row.
  • DROP CONSTRAINT IF EXISTS, then ADD, for each constraint.
  • The new FK on inbox_attachment and the replaced kind check are added NOT VALID, following 20261015114324_message_sender. Every existing row already satisfies both, so nothing is scanned under the lock.

JSON Schema goldens (LUKE_UPDATE_FIXTURES=1)

  • inbox-wire-inboxItemSchema.json: adds the optional messages array of inboxMessage, and the optional settledAt count.
  • inbox-wire-inboxAnswerSchema.json, inbox-wire-inboxAnsweredSchema.json, coding-agent-view-codingAgentInboxAnswerViewSchema.json, coding-agent-view-codingAgentInboxAnsweredViewSchema.json: the same messages and settledAt additions, through the item they embed.
  • New: inbox-wire-inboxMessageSchema.json (one message), inbox-wire-inboxMessageRequestSchema.json (the POST body), inbox-wire-inboxMessagedSchema.json (its answer), coding-agent-view-codingAgentInboxMessagedViewSchema.json (that answer or a failure, for the Gateway).

Evidence

  • Platform-independent checks: pnpm check:affected gets killed by the sandbox's 8 GB limit (exit 137) when its tasks run in parallel, so I ran its parts one after another. All passed:
    • pnpm knip, pnpm discarded-effect, pnpm oxlint, and biome check.
    • turbo run typecheck --concurrency=1 over every dependent of @luke/database and @luke/features, desktop and host included.
    • drizzle-kit check ("Everything's fine"), and db:generate reports no unmigrated schema.
    • The repository contract checks and the API callers check.
  • vitest run --project features --project database: 835 tests passed.
  • vitest run over coding-agents-app.test.ts, coder-host/, inbox/, and the web storage, functions, and services tests: 239 tests passed.

Invariants

  • ./scripts/check.sh green. (CI) Locally, its parts ran one after another, as above. CI runs it whole.
  • JSON Schema goldens unchanged (LUKE_UPDATE_FIXTURES not run, or diff explained line by line). (CI) Explained above.
  • Every behavior change has, in this PR, the test that fails without it; no change-detector test added. (manual; root AGENTS.md "Testing")
    • Store, PGlite (inbox-store.test.ts): the starter and a teammate post on an open item and on an answered one; an outsider writes nothing; status, answer, and revision stay; updated_at and the plan read's cursor move; messages read oldest first with their author. A second test covers a message's files kept in order and served, a repost keeping the thread and its files, a repeat under the same key writing once and moving nothing, other words under that key refused as the key reused, and a withdrawn message going with its files. A third covers settledAt.
    • Route (coding-agents-app.test.ts): a message on an answered item wakes an agent whose turn ended (status completed → running) and reaches eve under "Inbox message" with its words and file, while the item stays answered at its revision. A repeat reaches eve once, and other words under the same key are refused without reaching eve; a teammate posts once they join and is refused before; no bearer and empty words are refused. A second test checks that a hand-off eve refuses leaves no message on the thread.
    • Client (coding-agent-client.test.ts): the path, the trimmed body, the answer, a refusal named, and nothing sent for an empty message.
  • Gateway envelope goldens unchanged. (CI)
  • No new as type assertion outside the allowlisted files. (CI via anti-slop)
  • No Effect.runPromise/runSync/runFork outside the runtime edges and the shims tools/anti-slop/effect-edges.json records. (CI via anti-slop/no-run-promise-outside-edges)
  • No new setTimeout/setInterval/new Promise/AbortController/fs.watch outside the files tools/anti-slop/effect-edges.json records. (CI via anti-slop/no-raw-async-primitives)
  • No Effect, Stream, or Layer written as a statement and dropped; a description a caller walks past runs nothing. (CI via pnpm discarded-effect)
  • Test count equals the pre-PR count or the delta is listed. (manual: vitest run --reporter=json) +6: three store tests (the third covers settledAt through an answer, a close, a row settled before the column, a reopen, and a message on an open item), two route tests, and one client test.
  • Each hand-rolled file the PR replaces is deleted, or the part that remains is a permanent adaptor named in root AGENTS.md's "The permanent adaptors" with the reason it stays. Nothing is replaced.
  • Every CLAUDE.md/AGENTS.md sentence naming a changed part is edited; root pair stays byte-identical. None names these parts.
  • PRIVACY.md unchanged, or the change is a product decision called out in the PR body. Called out above.
  • Package package.json deps match what the sources import by bare specifier; effect via catalog:. (CI) Adds the ./schemas/inbox-message export.
  • Barrel/door rule: no Node-reaching Effect module (@effect/platform-node, effect/sql) behind a barrel the renderer or a web function opens. (CI: existing renderer node: grep plus a new grep for those specifiers in renderer bundles)

Notes

  • Follow-ups:
    • The desktop half builds on this branch.
    • PR 2 (the agent's read_inbox and reply_inbox) stacks on it.
    • Whichever lands second of this PR and the realtime stack should add messages to the scope live statement, as noted under Summary.
  • Preview run: docs/WORKFLOW.md step 3's ./scripts/run.sh --preview needs the desktop half to draw the thread. Until then the route is covered by the tests above.

Made with Luke


Devin Review


Summary by cubic

Lets any workspace member add follow-up messages to an inbox item's thread, beyond the single answer an item holds today.

  • New inbox_message table stores each message with its author (member or the item's agent), Markdown text, files, and a per-item client_key for idempotent retries.
  • New POST /api/inbox/{id}/messages accepts { text, attachments?, clientKey } from any member of the item's workspace, on open, answered, or closed items.
  • A post writes the message and its files and moves only updated_at, leaving status, answer, and revision untouched. inbox_item now stamps settled_at when an item is answered or closed and clears it when it opens, so a message no longer moves when the item settled.
  • The message reaches the agent through the same steer as a tab message, under an "Inbox message" lead, waking a stopped agent. A failed hand-off withdraws the message only for the send that wrote it; a send under a key that already named another message is refused.
  • Item reads carry up to 50 messages, oldest first, each with author, text, files, and timestamp. Message files reuse inbox_attachment under the new message kind.

Migration

  • inbox_message is created with CREATE TABLE IF NOT EXISTS.
  • inbox_attachment.message_id and inbox_item.settled_at are added as nullable, no-default columns (catalog-only changes); no backfill, as an item settled before the column reads its last change as settled until its first message.
  • Indexes and constraints are added idempotently; the new FK and kind check are NOT VALID, so no existing rows are scanned under lock.

Written for commit 03a363e. Summary will update on new commits.

View guided diff Turn on auto-fix

@vercel

vercel Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
luke-web Ready Ready Preview Oct 17, 2026 1:52pm UTC

Request Review

devin-ai-integration[bot]

This comment was marked as resolved.

@luke-agent
luke-agent Bot added this pull request to the merge queue Oct 17, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 17, 2026
dastratakos and others added 3 commits October 17, 2026 13:48
…ages

An inbox item now holds a thread beside its one answer. A member of the
item's workspace can add a message to it at any point, open or settled,
through POST /api/inbox/{id}/messages: Markdown words and the files a
message to an agent may carry. The message is kept in the new
inbox_message table, its files as inbox_attachment rows of the message
kind, and it moves the item's updated_at and nothing else of it, so the
status, the answer, and the revision stand. It reaches the agent through
the steer under its own lead, "Inbox message", naming the item's key and
title, which wakes an agent that stopped as a tab message does. A
hand-off that fails takes the message back off the thread, and a message
sent again under its key is written once.

Every item read carries its latest messages oldest first, each with its
author, words, files, and time, and a post under the item's key keeps
the thread and its files.

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
… item settled

A member's message under a key that already names another message, by
another member or with other words or files, is refused, so the agent
never hears what the thread does not show. Only the send that wrote a
message takes it back when its hand-off fails.

inbox_item gains settled_at, written when an item is answered or closed
and cleared when it opens again, and the wire carries it as settledAt, so
a message moving updatedAt no longer moves when an item settled. An item
settled before the column reads its last change as settled until its
first message, which keeps that instant first, so no backfill is needed.

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
main gained 20261017121543_coding_agent_start_branch, which this
branch's migration did not commute with, so the folder is generated
again from the rebased schema with the same hardened statements.

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
@luke-agent
luke-agent Bot force-pushed the luke/inbox-thread-messages branch from 7625beb to 03a363e Compare October 17, 2026 13:49
@luke-agent
luke-agent Bot added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit 67bb4dd Oct 17, 2026
12 checks passed
@luke-agent
luke-agent Bot deleted the luke/inbox-thread-messages branch October 17, 2026 14:07

This branch was successfully deployed

1 active deployment
Preview — 03a363e4 Deployed Oct 17, 2026 by vercel[bot]
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