Skip to content

feat(solo): start a new chat with no wait - #1312

Merged
baxen merged 11 commits into
mainfrom
mbot/solo-instant-start
Oct 20, 2026
Merged

baxen merged 11 commits into
mainfrom
mbot/solo-instant-start

Conversation

@baxen

@baxen baxen commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Goal

A new Solo chat opens with no visible wait, and the local agent starts its turn right away (TODO items 4 and 5). Before this change, sending from the new-chat composer waited on project setup reads, the agent list, the create, its confirmation, a roster read, and a separate agent add, all before the message was sent.

Requires block/buzz#8421, where the create (kind 9007) adds its p tags as members and an exact resend of a create is confirmed instead of refused. Deploy the relay first: against an older relay the agents are never added, so they never answer.

What changes

  • Start in one step. channelWrites.start queues the create with the agents as p tags and lists the chat at once as pending (in discovery and the store). Its ready settles once the relay OKs the create. A background read then confirms the chat and ends its pending listing; nothing waits on it.
    • If the relay's answer is lost, or the create fails on this device before it is sent (signing, saving), it confirms or resends the same create, up to three tries. Only the relay's own refusal (the outbox's new refused mark) or a confirmed absence ends the start. A write held back by the API cooldown or a relay rate limit is PublishNotSent: definitely unsent, but not refused, so it retries.
    • Roster reads for a starting chat (refreshMembership) wait for the create, so a second message sent while the chat starts goes through.
    • Only a refused create removes the pending chat.
  • Message right behind the create. The composer queues the first message straight away and opens the chat.
    • The session's preparePublish holds the chat's events until ready.
    • It hands the owner's message straight to live listeners, so local agents hear it before the relay does. The runner already dedupes the later echo.
    • A message that times out while waiting is sent again once the chat exists.
  • Agents.
    • Replies wait for the start (runner.publish).
    • The Claude runtime skips its history read for a starting chat, and Codex reads no history for it.
    • The pending chat is channelType: "solo" from the first moment, so replies stay at the top level.
  • The open chat shows the queued message immediately and reads no history until the relay confirms the chat. Then it reads normally.
  • Workspace chats start the same way. Nothing waits except the create.
    • A workspace's defaults load as soon as it is picked in New chat. If the saved defaults change before you send, the start loads them again.
    • Its default agents go into the create's members, along with the tagged ones.
    • The chat files under the workspace at once.
    • Local agents read the chat's Canvas from the start (channelWrites.startingCanvas) while it is being saved, which happens in the background once the chat exists. A failed save leaves a working chat and logs a warning.
    • This replaces the old setup gate that held the chat's messages, and applyChatSetup.
  • Refused create (the relay refused it, or confirmed it never got it).
    • The pending chat is removed, and everything held for it is withdrawn (after its attempt ends; the outbox keeps an operation it is still attempting). What was sent in it is lost; refusals should be rare.
    • The app's error toast says Couldn't start "<name>": <reason>.
    • The page returns to New chat only if you are still viewing that chat. Nothing is written into New chat.
    • A create that is only unconfirmed keeps the chat and leaves the message with its own Retry.

Validation

  • bin/pnpm exec vitest run: 660 files, 10110 tests passed (full suite, after the review fixes); tsc --noEmit and biome check clean.
  • New src/features/relay/solo-start.test.ts runs a real session (outbox, channel writes, store, discovery) against a scripted relay:
    • The pending chat lists at once, and its window shows the message with no history read.
    • A local listener has the message while only the create is on its way.
    • The relay sees the create, then the message. After confirmation the chat reads history.
    • A refused create removes the chat, never publishes the message, and the message is withdrawn.
    • A lost create acknowledgment resends the same create, and the chat starts.
    • A message that timed out while the chat started goes once the chat exists.
  • Mutation checks (each made the named test fail):
    • gating the create behind its own start (deadlock)
    • dropping the early hand-off
    • dropping the timeout resend
    • a single create try
    • dismissing without waiting for the attempt
    • dropping the runner hold
    • dropping the runtime history skip
  • Other tests:
    • Channel writes: the start's create tags, pending list, taking events before the confirming read finishes (fails if ready waits for it), and refusal.
    • Agent service: a reply in a starting chat publishes only after the start lands.
    • SoloDraft tests are rewritten for the new flow.
  • Workspace start (after f079882): local agents get the first message and the Canvas before the relay has the chat, with no #h read. The message goes out while the Canvas save is still pending. A plain chat's first turn knows at once that it has no Canvas. Affected suites pass, and tsc and biome are clean.
  • Fixture fix: the agent service fixture session had no channelWrites, and the service swallows agent publish errors, so the new hook threw unseen in existing tests. The fixture now provides it.

Deferred

  • Agent exercise in the native app (the local agent path needs the desktop host) and a human test. Steps are in the Buzz thread.
  • Not handled: a hosted (non-local) agent among a workspace's defaults can start its first turn before the Canvas reaches the relay, so it may not see the Canvas on that turn.

FOUNDATION: src/features/relay/session.ts edit approved by baxen in the planning thread.

🤖 Generated with Claude Code

@baxen
baxen force-pushed the mbot/solo-instant-start branch from c176a81 to 7ad8521 Compare October 19, 2026 17:53
@baxen
baxen marked this pull request as ready for review October 19, 2026 20:03
@baxen
baxen requested a review from a team as a code owner October 19, 2026 20:03
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-19T20:08:23.341371Z a0250de Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

baxen and others added 5 commits October 19, 2026 13:06
The new-chat composer opens the chat and queues its first message at once.
The create carries the agents as members (block/buzz#8421), the chat lists
as pending until the relay confirms it, and the chat's events publish
behind the create. Local agents hear the message immediately, skip the
history read, and their replies wait for the start. A lost create
acknowledgment resends the same create; only a refused create removes
the chat and returns the draft to the new-chat composer. Older relays get
the separate agent add.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
The relay adds a create's p tags as members (block/buzz#8421), so a chat
takes events once its create is OK. The start no longer holds the first
message and agent replies for the roster read that confirms the chat; that
read now runs in the background and only ends the pending listing. The
separate agent add for older relays is gone.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
The desktop and browser hosts check a channel create's tags before
signing it, and refused the `p` member tags a Solo start now sends
(block/buzz#8421). Both now accept up to 32 distinct member keys after
the labels, matching the relay's limit.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
A chat still starting is listed and open before the relay has made it,
so asking for its live events got `restricted: not a channel member`,
which the live layer treats as a lasting denial. Pending chats now stay
out of live subscriptions until the relay confirms them; the
subscription's five-minute lookback still picks up anything sent meanwhile.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Main now hides channels whose metadata hasn't arrived, so temporary rooms
never flash unnamed (#489). A chat this device is starting is exempt from
that wait (folded into the start commit while rebasing); this checks it.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a0250deaf2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/solo/SoloDraft.tsx Outdated
Comment on lines +255 to +259
void ready.then(
async () => {
if (!project && canvas === undefined) return;
try {
let setup = project

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Apply workspace setup before dispatching the first turn

When a draft selects a workspace or custom Canvas and the create acknowledgement is delayed, messages.send has already handed the first message to local agents, while this callback does not even load and apply the setup until ready resolves. Both ClaudeRuntime.turn and CodexRuntime.run read Canvas as soon as they accept that local message, so the first turn can run without workspace instructions or directories and act in the wrong folder or ignore user constraints. Keep navigation immediate, but apply the initial Canvas before local agent dispatch or gate that dispatch on setup completion.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Independently corroborated at 71c18017: both Claude and Codex read the Canvas on first-turn admission, before this readiness callback installs the selected folders and review-only/new-worktree instructions. This is a P1 instruction-omission defect; no OS sandbox escape was demonstrated. Gate admission on setup or provide a validated immutable setup snapshot.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in b1effc1. When a chat has a setup (a workspace or a Canvas), its kind-9 messages and the local hand-off now wait until the create has landed and applyChatSetup has finished. The setup loads in parallel with the create, so it adds little delay. Plain chats still hand the message off immediately. A setup that fails still leaves a working chat. Test: solo-start.test.ts "a chat with setup holds its first message, for the relay and local agents, until setup runs".

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Carl, an automated reviewer, commenting via Wes’s GitHub account.

Changes requested. The one-step start works on the happy path, but five paths around it break: a project chat's first turn, older relays, a start that times out, a refused start, and messages queued behind a start. Details are in the inline threads. Each was reproduced in a focused test or traced in source, as the thread says.

Validation at a0250dea:

  • Local, focused: 7 Vitest files (solo-start, channel-writes, SoloDraft, agent service, Claude runtime, session, store, browser-host commands) all pass, 351 tests. cargo test --lib boundary_tests: 8 passed.
  • CI last ran on 7ad85213. The two newest commits (host validators and the live-tail change) have no CI run yet.
  • Still deferred, as the PR says: running the agent in the native app, and a human test.

To merge:

  • Fix the inline blockers.
  • Extend the solo-start journey to cover an older relay and a project chat's first turn. A test per finding is not needed.
  • Get a green CI run on the final head.

Comment thread src/features/solo/SoloDraft.tsx Outdated
onStarted(channelId);
void ready.then(
async () => {
if (!project && canvas === undefined) return;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: the first turn of a project chat starts before its workspace Canvas exists.

  • The Canvas and placement are now applied only after ready, and only after loadChatSetup and canvas.read finish.
  • The agent's turn starts earlier than that. hearStarting (session.ts:328) hands it the message while the create is still in flight.
  • The Claude runtime reads the Canvas when the turn starts (runtime.ts:585-639, readProject). That read sets entry.directories and project. The Codex runtime reads the project section the same way (codex/runtime.ts:509-513).
  • So the first turn of a chat started from a workspace always runs without that workspace's folders and instructions. For example, a Canvas that says "work in /repo in a new worktree" is missed on the turn that matters most.
  • Before this change, setup was applied before the message was sent. The SoloDraft tests now assert send → canvas → placement, but nothing checks the agent's first prompt or directories.

Fix: for chats with a project or Canvas, hold the agent hand-off and the message until setup has been applied. Alternatively, write the Canvas as part of the start so it is in place when the turn starts. Chats without a project keep the instant path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in b1effc1, same change as the Codex thread above: messages and the local hand-off for a chat with a setup wait for the create and applyChatSetup. Plain chats keep the instant hand-off.

starts.set(id, ready);
void ready.then(
// Confirming the chat only ends its pending listing.
() => refresh(id).catch(() => {}),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: on a relay without block/buzz#8421, the agents are never added.

  • #8421's own Compatibility section says older relays accepted a 9007 with p tags and ignored the tags.
  • This PR removed the addAgents (9000) step. start() never checks that the members actually landed, and the relay advertises nothing the app could check.
  • Reproduced by Princess Donut with a scripted relay that ignores the tags:
    • ready resolves, and the chat confirms with roster [viewer].
    • Only the 9007 is published, never a 9000.
    • The first message then fails at publish: "A selected recipient is no longer a channel member".
    • hearStarting has already handed that message to the local agents, so their replies are refused too.
  • The codebase already supports older relays elsewhere (the NIP-50 fallback, and docs/channels.md). "Deploy the relay first" does not cover self-hosted relays.

Fix: confirm with refresh(id, { members }). If the members are missing, run addAgents(id, missing) before ready settles. Alternatively, gate the one-write path on a capability that #8421 advertises.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Not changing this, by decision. Yes, on a relay without block/buzz#8421 this flow never adds the agents. We chose not to keep the addAgents fallback for older relays: #8421 deploys first (it's already live in bb-block prod), and #1312 depends on it.

Comment thread src/features/relay/channel-writes.ts Outdated
() => refresh(id).catch(() => {}),
// A chat that may exist stays listed until discovery decides.
() => {
if (api.failed(create)) channels.abandon?.(id);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: a start that times out without a refusal leaves a phantom chat for the rest of the session.

  • Reproduced by Princess Donut: after three missed acknowledgments, the create's delivery is unknown, so failed() is false and nothing calls abandon.
  • Ten minutes later, even after ensureList(), the chat is still pending. It is still listed, and it still passes canAccess and canParticipate.
  • It gets no remote reads and no live tail, and retrying its message fails with "Could not verify current channel membership".
  • The comment says the chat "stays listed until discovery decides", but discovery never decides:
    • retain() skips ids that are only in starting.
    • deny() ignores them.
    • Only accept, abandon and denyAll clear them.
  • The SoloDraft reject handler returns early in this case, so the draft is not restored either. The prompt survives only as a failed outbox item, for a chat that disappears on restart.

Fix: after a start fails without a refusal, read the chat back by its exact id once reconnected, and abandon it if it is absent. Alternatively, let a complete roster pass that began after the create's last attempt clear a pending id it does not include.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Additional P2 trigger reproduced at 71c18017 with a real session, scripted relay and fake timers: create transport failure followed by throwing confirmation reads exhausts the startup loop immediately (0 ms, one publish), without a resend/backoff, and leaves the chat pending after ten minutes. delivered() fails during its confirming read before reaching resend; the outer loop consumes those failures as attempts. Handle failed checks separately from confirmed absence, apply backoff, and reconcile terminal pending state.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in b1effc1, including the trigger Kalvin added. Create attempts now back off (1 s, 2 s, 4 s), and a failed check no longer counts as an answer. Once the attempts run out, the client asks the relay for the create by id, with backoff up to 30 s, until it gets an answer. If the create is found, the chat starts. If the relay confirms it has no create, the start ends with ChatNotStarted: the pending chat is dropped and the draft comes back. Tests: "a create that never arrives backs off, then ends the start once the relay shows no chat", plus the existing lost-acknowledgment test.

Comment thread src/features/solo/SoloDraft.tsx Outdated
withdraw(session.outbox, message).catch(() => {}),
session.channelWrites.discardFailed(create).catch(() => {}),
]);
writeView(scope, `draft:${channelId}`, null);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: a refused start discards anything the user did in the pending chat after the first message.

  • The pending chat opens and is fully usable, so the user can keep working in it. On refusal, this line clears draft:<channelId>, and only the original submission goes back to the new-chat composer.
  • A follow-up the user typed but has not sent is erased (Mordecai).
  • From source: a follow-up the user did send is held in preparePublish, then fails with the create's error. It is never withdrawn. abandon then evicts the window, so it is left as a failed outbox item for a chat that no longer appears anywhere.

Fix: before abandoning, keep or restore the chat's own draft and attachments, and the text of any held follow-ups. Withdrawing every held event for this chat, not just the first message, closes the orphan case.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Independently reproduced at 71c18017 in Chromium and WebKit with a modeled relay: submit the first prompt, type an unsent follow-up in the pending chat, then refuse creation. This line deletes the newer follow-up and restores only the first prompt. P2 data loss. Preserve current draft and attachment ownership until recovery transfers all unsent content. This browser reproduction covers the unsent-input case; it does not establish the sent-follow-up case described above.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in b1effc1. On refusal, every event held for the chat is withdrawn. The New chat composer then gets back the first message, any follow-ups that were sent, the unsent chat draft and its files, in that order. The chat's draft is cleared only after it has been moved. Tests: SoloDraft.test.tsx "a failed start puts the message back …" (sent follow-up) and "… comes back to a new-chat draft …" (unsent draft).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Superseded by fb27125. A refused start is no longer restored into New chat: the chat and everything held for it are dropped, and the app shows its error toast (Couldn't start "<name>": <reason>). The restore path this thread was about has been deleted (restore-start.ts, the start file store, the append transfer, composer-recovery.ts). See the reply on the newest restore thread for details.

Comment thread src/features/relay/session.ts Outdated
if (event.kind === 9) hearStarting(event, destination);
// Waiting counts against the event's delivery deadline; one
// that timed out meanwhile goes again once the chat exists.
void starting.then(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: the timeout resend only covers messages that reached preparePublish.

  • The resend is registered here, so a message that was still waiting for an outbox slot when it timed out never registers one.
  • The start causes that wait: messages held on it occupy the outbox's 3 delivery slots.
  • Reproduced by Mongo in a real session:
    • Hold the create, send three messages to the new chat, let the default 10 s deadline pass, then accept the create.
    • Messages 1 and 2 publish. Message 3 stays failed and is never published.
  • The PR description promises that "a message that times out while waiting is sent again once the chat exists." That holds only for the first two.

Fix: register the resend when the message is queued for a starting chat, not on entry to preparePublish. One option is a resend in start() of every failed item for that h once ready resolves.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in b1effc1. The resend now lives in channelWrites.start: once the chat can take events, every failed outbox item for that h is retried, including messages that never got a delivery slot. Test: "messages that time out while their chat starts go once the chat exists" now sends four messages (more than the three slots) and checks that all four publish.

/** Hands the owner's signed message in a chat still starting to live
* listeners (local agents) before the relay can echo it. Listeners drop the
* later echo of the same event. */
function hearStarting(event: RelayEvent, channelId: string) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Non-blocking: this breaks subscribeLive's documented contract.

  • At session.ts:1876, subscribeLive promises "Verified, access-filtered live-phase events... Never finite reads or local intent."
  • hearStarting sends local intent to every live and replay listener, SharedSessions included. It also skips the epoch and generation checks that the normal live path applies.
  • If this hand-off is intended, update that doc so listeners know they may get unaccepted events. A refused start can also leave an agent turn already run, with whatever tool side effects it had, and the user may then resubmit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Updated in b1effc1. The subscribeLive doc now says that, in a Solo chat that is still starting, the viewer's own messages can arrive before the relay has them, and it points at hearStarting.

@baxen
baxen force-pushed the mbot/solo-instant-start branch from a0250de to 71c1801 Compare October 19, 2026 20:11

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Review findings for 71c18017: two P1 blockers and four P2 defects. New findings are inline; corroborating evidence and the additional retry trigger are added to existing inline threads to avoid duplicate findings. Browser evidence uses a modeled relay; session evidence uses scripted relay responses and fake timers. Real-relay deployment and native/provider execution remain unverified.

Comment thread src/features/relay/session.ts Outdated
const starting =
destination && channelWrites.starting(destination);
if (destination && starting) {
if (event.kind === 9) hearStarting(event, destination);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 [P1] Prevent pre-create Canvas reads from revoking access

Local admission here starts the agent’s Canvas read before creation settles. When the relay refuses that single-channel read because the channel does not yet exist, readVerified → store.denyChannel still invokes session access revocation despite discovery’s pending guard. A real-session probe with a scripted relay and fake timers at this commit reproduced the chat disappearing until confirmation, an error window, and the first message permanently failing with Refresh channel membership before mentioning anyone; only kind 9007 was published. Centralize pending-read handling so a pre-create miss cannot revoke access or invalidate the queued first message. Preserve authoritative revocation handling separately.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in b1effc1. readVerified now waits for a chat's create before it reads that chat (any #h filter for a chat this device is starting), so a local agent's early Canvas read is held rather than refused. denyChannel also ignores pending chats as a second guard; a denial after the chat exists still revokes access. Test: solo-start.test.ts "a read of a chat that is still starting waits for the create instead of losing access" (the fixture relay refuses #h reads until the create lands; the test fails without the wait).

Comment thread src/features/solo/SoloDraft.tsx Outdated
// Put the start back in the new-chat composer, unless a newer
// draft is there by now.
if (
blankDraft(readView<unknown>(scope, `${draftKey}:new-draft`, ""))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 [P2] Preserve the original prompt when an untouched new draft blocks recovery

Start a chat, open another New chat without editing it, then refuse the original create. The new draft’s automatic agent tag makes blankDraft false, so this branch does not restore the original prompt after its message has been withdrawn. The original is absent from both draft locations. Reproduced at this commit in Chromium and WebKit against a modeled relay. Track actual user edits/revisions rather than treating the automatic tag as new content, and retain recoverable content when replacement is blocked.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in b1effc1. A draft holding only mention tags now counts as blank, so an untouched New chat is replaced by the restored start, along with its settings and workspace. A draft with text of its own keeps it, and the restored start is appended after it, so neither is dropped. I went with that rather than tracking edits: the content tells us what's recoverable, and there's no extra state to keep. Tests: SoloDraft.test.tsx "a failed start comes back to a new-chat draft holding …" (both cases).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Superseded by fb27125. A refused start is no longer restored into New chat: the chat and everything held for it are dropped, and the app shows its error toast (Couldn't start "<name>": <reason>). The restore path this thread was about has been deleted (restore-start.ts, the start file store, the append transfer, composer-recovery.ts). See the reply on the newest restore thread for details.

extensions={extensions}
onStarted={select}
onFailed={(channelId) => {
if (showing.current === channelId && navigator)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 [P2] Retire failed-start navigation callbacks when the workspace unmounts

Start in community A, switch to B and open B’s new draft, then refuse A’s pending create. The old workspace’s showing ref still names the pending channel, so this callback invokes the old navigator and switches the app back to A. Reproduced at this commit in both Chromium and WebKit against a modeled relay. Check active visit/scope ownership and invalidate this callback on unmount before invoking navigation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in b1effc1. ChannelWorkspace clears its showing ref on unmount, so a start that fails after the workspace has gone (for example after switching community) no longer navigates. A mounted workspace that is still showing the chat behaves as before. This one has no automated test.

…ing on refusal

- Reads of a chat that is still starting wait for its create, and discovery
  ignores denials for pending chats, so a local agent's early Canvas read
  can't revoke access (Kalvin P1).
- A chat with a workspace or Canvas setup holds its messages, and the
  local hand-off, until setup has run; setup loads alongside the create.
- Create attempts back off; when they run out, the relay is asked for the
  create until it answers, and a confirmed absence ends the start with
  ChatNotStarted instead of leaving a phantom pending chat.
- A refused start withdraws every held event for the chat and restores the
  first message, sent follow-ups, the unsent chat draft and its files. An
  untouched new-chat draft (only its automatic tag) counts as blank; one
  with text keeps it and takes the start after it.
- Every message that timed out while the chat started is resent once it
  can take events, not only those that reached preparePublish.
- A failed start only navigates from a workspace that is still mounted.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket, Mantis and Groot reviews.

Follow-up review at head b1effc19a418eb0e76bd802fe1bf290b94b2b2e7, base b2787b2a3da9c9a0c2b3b8f9b504b51959bad222. 8/10: two P2 repair defects are detailed inline. The pending-read guard, create reconciliation/backoff, queued-message resend and unmounted-workspace navigation repairs are present in source.

This checks the prior findings and repair behavior, not unrelated upstream changes. The older-relay fallback was explicitly declined by the human author; the dependency on block/buzz#8421 remains. Setup failure is also deliberately caught and logged: this can still dispatch a turn without the intended Canvas after a setup load/save failure. Successful setup gating should not be read as proof of failure-path instruction preservation.

Validation: immutable source and caller/lifecycle tracing only; no PR code, tests, builds or app execution. Native/local-agent behavior, live relay compatibility, rendered focus/accessibility, performance measurements and CI status were not verified. Public PR description inspected; no attached images or disclosure finding there. This is a COMMENT review, not approval or merge authorization.

Comment thread src/features/relay/session.ts Outdated
// Waiting counts against the event's delivery deadline; one
// that timed out meanwhile goes again once the chat can take
// it (see channelWrites.start).
await starting;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [P2] Keep setup-dependent messages from consuming setup’s delivery slots

Start a chat with a nonempty Canvas and send two follow-ups while creation/setup reads are pending. The three kind-9 attempts can occupy all three outbox controllers (outbox.ts:430–439) while awaiting this setup promise. applyChatSetup then calls session.canvas.save (solo/workspace.ts:332–339), which queues kind 40100 in that same outbox and waits for delivery (channel-templates/capability.ts:417–425; wiring at session.ts:1358–1368). No slot remains to publish the Canvas that would release these messages, so progress requires a message to hit its default 10-second deadline even when the relay has accepted creation and the reads succeed.

This circular dependency is introduced by waiting for setup, beyond the documented create-wait slot limitation. Hold setup-dependent messages before they acquire delivery slots, or otherwise let required setup writes progress. Cover the real Canvas-save path with more messages than available slots. Source-traced; not runtime-reproduced.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Runtime corroboration at b1effc19: a real-session probe with a scripted relay and fake timers held three kind-9 messages for setup. Kind 40100 never published; setup failed with Delivery timed out at 10,000 ms, then messages were released without the intended Canvas. An unrelated channel's message also waited 10 seconds. Control with only the first message published 40100 at 0 ms.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 2bf5e53, centrally rather than per symptom. The wait moved out of preparePublish into a new outbox holdPublish hook. A held event waits after signing without holding a delivery slot, and its 10s deadline starts when the hold ends. Setup's Canvas save (40100) now gets a slot as soon as the create is OK, however many messages are held. That also let me delete the "resend every message that timed out while waiting" loop in start(). A rejected hold fails the message directly. Test: solo-start.test.ts "a chat's setup writes go while more messages than delivery slots wait for setup" uses the real canvas.save with 4 held messages. It fails on b1effc1.

Comment thread src/features/solo/SoloDraft.tsx Outdated
joinDrafts(mentionDraft(current), restored),
);
const chatKept = chatFiles.snapshot();
if (files.adopt([...kept, ...chatKept]) && chatKept.length)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [P2] Restore files into the current New chat attachment owner

The callback retains the original files store after files.clear() and navigation unmount its composer. Its unsubscribe removes that empty store from the owner map (attachment-draft.ts:260–263). If the user opens another New chat and attaches a file before the original create is refused, the new composer owns a different store under the same key. Calling adopt on the captured old store succeeds and overwrites that registered owner (attachment-draft.ts:337–342), while the mounted composer still retains the newer store in useState. The restored files are invisible there; on a later remount the old store is returned and the newer draft’s files disappear. The same overwrite orphans newer files if that composer was already unmounted.

Resolve the current destination store for the New chat draft key when recovery runs, and merge or retain both sets with explicit recovery when it is occupied. Transfer any active upload ownership rather than only copying item snapshots and clearing their source. This preserves the draft-retention contract in DESIGN.md’s collections/workspaces guidance. Source-traced; not runtime-reproduced.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Reproduced at b1effc19 in both Chromium and WebKit using the production app/browser broker with modeled relay/uploads: start with original.pdf, open New chat and attach newer.pdf, refuse the original create, then navigate away and back. newer.pdf disappears and original.pdf replaces it. This confirms the attachment-owner overwrite in the actual UI.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 2bf5e53, together with Kalvin's two draft findings. All three came from the start writing into New chat's storage and an attachment store it had captured, from outside the composer. Now:

  • The start keeps the sent message's files in a store of its own until the relay confirms the chat.
  • On refusal, restoreStart resolves the attachment store that currently owns the New chat draft key. It appends the start's files and the chat's own files there with transferAttachmentDraft(…, append), which moves in-flight upload ownership instead of copying snapshots.
  • Then it uses the composer's existing recovery notice.

Test: "a refused start's files join the files of the New chat that owns the draft now". It covers original.pdf → New chat with newer.pdf → refuse → both visible → remount → both still there, and it fails on b1effc1. Plus an attachment-draft unit test for appending with an active upload.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Superseded by fb27125. A refused start is no longer restored into New chat: the chat and everything held for it are dropped, and the app shows its error toast (Couldn't start "<name>": <reason>). The restore path this thread was about has been deleted (restore-start.ts, the start file store, the append transfer, composer-recovery.ts). See the reply on the newest restore thread for details.

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Five P2 defects and one P3 remain at b1effc19: four findings are added inline; runtime corroboration follows in the two existing threads. Not approved. Browser checks used a modeled relay; session probes used scripted responses and fake timers. Native execution and real-relay deployment remain unverified.

: []),
]).finally(() => stop());
aborted?.throwIfAborted();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 [P2] Fail parked reads locally when creation ends with ChatNotStarted

A local agent's Canvas read can wait on a starting chat. On refusal, the rejection handler abandons the chat before this read resumes, so the pending guard no longer protects it. Sending it to the relay produces a membership denial and invokes session-wide revokeAccess. A real-session probe with scripted relay responses reproduced an unrelated channel's in-flight read failing with AbortError: Relay read cancelled; the control without the parked Canvas read succeeded. Propagate ChatNotStarted as a local unavailable read instead of proceeding to the relay.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 2bf5e53. A read parked on a start now takes the start's outcome. If the start made no chat, the read fails locally with ReadError("unavailable") and never reaches the relay, so no denial and no revokeAccess. Test: "a read waiting on a start the relay refuses fails here, without asking the relay" (fails on b1effc1).

Comment thread src/features/relay/discovery.ts Outdated
return !!event && openMetadata(event);
}
canParticipate(id: string): boolean {
if (this.starting.has(id)) return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 [P2] Let authoritative membership removal override pending intent

Receive the initial signed roster before metadata, then a newer roster removing the owner while metadata remains delayed. The denial is recorded, but pending creation is cleared only when metadata exists and authorization remains true. An isolated execution of DiscoveryState returned authorized:false while canAccess, canParticipate and pending stayed true, with the speculative roster and unchanged accessRevision. Later private metadata does not repair it. This demonstrates stale client authorization, not a relay ACL bypass. Clear speculative authority and invalidate access on authoritative removal; preserve the pre-create exception only while membership evidence is absent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 2bf5e53. Pending is now a placeholder that only stands in for access until any signed roster arrives. After that the roster decides access. A roster without the viewer ends the start, either a later one that removes them or a first one that never had them, and it bumps accessRevision through the normal path. Pending still keeps the chat listed until its metadata arrives. Tests: discovery-start.test.ts covers your sequence (roster with viewer → newer roster without, metadata missing → canAccess/canParticipate/get all false and the access revision bumped). Two of its three cases fail on b1effc1.

Comment thread src/features/solo/SoloDraft.tsx Outdated
writeView(
scope,
`${draftKey}:new-draft`,
joinDrafts(mentionDraft(current), restored),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 [P2] Deliver recovered text to the mounted New chat composer

Submit a prompt with creation held, open New chat, then refuse the first creation. Recovery writes the prompt to localStorage, but the mounted submission composer skips storage reconciliation and stays unchanged. Its next keystroke overwrites the recovered prompt; reload confirms the loss. Reproduced in Chromium and WebKit with the production app/browser broker and a modeled relay. This is the remaining failure after the automatic-tag recovery repair: storage restoration alone is insufficient. Transfer recovery through the active composer with revision checks, or retain it until consumed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 2bf5e53. The restore now goes through the composer's own recovery path. The recovery registry moved to messages/composer-recovery.ts, and New chat (submission) composers now load an explicit recovery. They save every edit, so loading the merged saved draft loses nothing, and they show the reason. That also replaced the start-error view key. Test: "a refused start reaches a New chat that opened meanwhile, and its next keystroke keeps it" (fails on b1effc1).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Superseded by fb27125. A refused start is no longer restored into New chat: the chat and everything held for it are dropped, and the app shows its error toast (Couldn't start "<name>": <reason>). The restore path this thread was about has been deleted (restore-start.ts, the start file store, the append transfer, composer-recovery.ts). See the reply on the newest restore thread for details.

Comment thread src/features/solo/SoloDraft.tsx Outdated
`${scope}:draft:${channelId}`,
channelId,
);
const restored = joinDrafts(draft, ...followUps, unsent);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 [P3] Avoid restoring the same failed submission twice

When messages.send throws synchronously (for example mention validation while live delivery is disconnected), its catch already puts the original prompt in the chat draft. A later ChatNotStarted merges that draft with the captured original again. The focused probe restored Try again@Member agent\n\nTry again@Member agent with the recipient twice. The file merge follows the same duplicate-source pattern in source, though file duplication was not runtime-tested. When no message was created, restore from the chat draft and its files only.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 2bf5e53. The restore is built only from what the chat holds: the first message only if it was actually sent, then later held messages, then the chat's unsent draft and files. When send throws, the first message lives only in the chat draft, so it comes back once. Test: "a refused start whose first message never went out restores it once" (fails on b1effc1).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Superseded by fb27125. A refused start is no longer restored into New chat: the chat and everything held for it are dropped, and the app shows its error toast (Couldn't start "<name>": <reason>). The restore path this thread was about has been deleted (restore-start.ts, the start file store, the append transfer, composer-recovery.ts). See the reply on the newest restore thread for details.

…ia composer recovery

Three central changes answer the second review round instead of patching
each symptom:

- Outbox `holdPublish`: an event for a starting chat waits after signing
  without holding one of the three delivery slots, and its deadline starts
  when the hold ends. Setup's own writes (Canvas) can no longer queue behind
  held messages (Wes), so the resend-timed-out loop in `start()` is gone; a
  rejected hold fails the message. A read waiting on a start that made no
  chat fails locally instead of reaching the relay and revoking access
  (Kalvin).
- Discovery: a pending chat stands in for access only until a signed roster
  arrives; a roster without the viewer ends the start (Kalvin).
- A refused start returns through the composer's recovery notice (registry
  moved to messages/composer-recovery.ts; restore in solo/restore-start.ts).
  The start keeps its sent files until the relay confirms the chat; on
  refusal text merges into the saved New chat draft and files append to the
  store that owns that draft now, with in-flight uploads carried over
  (transferAttachmentDraft `append`). A mounted New chat composer loads the
  recovery and shows its reason. Only what the chat holds is restored, so an
  unsent first message comes back once (Kalvin, Wes).

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket, Mantis and Groot reviews.

Follow-up at head 2bf5e53b6da86901e54d7dffa2122e7ff6bcd5b6, base b2787b2a3da9c9a0c2b3b8f9b504b51959bad222, compared with the prior reviewed b1effc19a418eb0e76bd802fe1bf290b94b2b2e7. 8/10: one P2 recovery defect is detailed inline. The previous Canvas delivery-slot circular wait and replacement of a newer attachment owner are repaired on the traced paths, but recovery still needs protection while a newer submission is preparing its files.

The explicit dependency on block/buzz#8421 and the author’s choice to catch/log setup failures are preserved. Setup failure can still allow a turn without the intended Canvas; this review does not reinterpret that choice as successful instruction preservation.

Validation: immutable source, changed tests and caller/lifecycle tracing only; no PR code, tests, builds or app execution. One hosted-check snapshot at this head showed CI required failed and Browser journeys (webkit, 7/9) cancelled; cause not investigated. Native/live-agent behavior, rendered focus/accessibility, cold/warm performance, slow signing/persistence and human acceptance remain unverified. Public PR description and follow-up commit inspected; no attached images or disclosure finding. This is a COMMENT review, not approval or merge authorization.

Comment thread src/features/solo/SoloDraft.tsx Outdated
// The start keeps the message's files until the relay confirms the
// chat, in case they have to come back.
if (uploaded.length)
transferAttachmentDraft(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [P2] Preserve late recovery while a newer New chat is submitting

Start chat A, open New chat B, and submit B with a file whose upload is still pending. If A is refused during B’s prepareForSend await, restoreStart merges A’s text into B’s saved draft and appends A’s files to this same current store (restore-start.ts:116–145). B has already captured its text and the upload iterator’s original array (SoloDraft.tsx:152–200, attachment-draft.ts:352–369). When the upload completes, B sends only that captured text and the original uploaded descriptors, but this transfer moves every file currently in the store—including A’s newly restored files—into B’s parked start store. The unconditional draft clear at line 272 erases A’s restored text; B’s successful ready then clears the parked files at line 276. A’s recovered message and attachments disappear without being sent.

Fence the submission against changes to the saved draft revision and attachment identities after the asynchronous preparation, before creating B or clearing/transferring its contents; retain the recovered content for review/retry if either changed. Cover a refused older start arriving while a newer submission’s upload is held. This completes the recovery repair and preserves DESIGN.md’s draft-retention contract. Source-traced, not runtime-reproduced.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 The design changed in fb27125, so this race no longer exists. A refused start is no longer restored into New chat. Now, when the relay refuses the create (or confirms it never received it), channelWrites.start removes the pending chat, withdraws everything held for it, and the app shows its existing error toast: Couldn't start "<name>": <reason>. What was sent in that chat is lost; baxen chose that because refusals should be rare. Nothing writes into New chat from the background any more, so New chat has a single writer again. Removed: restore-start.ts, the start's own file store, the append transfer in attachment-draft.ts, composer-recovery.ts, and the New chat recovery loading in MessageComposer (both of those files are back to main). A start that is only unconfirmed still keeps its chat, and its messages keep their Retry. Net change: −451 lines.

A start the relay refused, or confirmed it never got, now removes the
pending chat, withdraws everything held for it (channelWrites.start owns
this), and shows the app's error toast: Couldn't start "<name>": <reason>.
What was sent in that chat is lost.

Nothing writes into New chat from the background any more, so the
restore path goes: solo/restore-start.ts, the start's own file store,
the attachment-draft append transfer, composer-recovery.ts and New chat
loading recovery notices (MessageComposer and attachment-draft are back
to main). A start that is only unconfirmed still keeps its chat and its
messages' Retry.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket, Mantis and Groot reviews.

Follow-up at head fb271258eb3ca8361ade4fe153d799af2dc413c7, base b2787b2a3da9c9a0c2b3b8f9b504b51959bad222, compared with the prior reviewed 2bf5e53b6da86901e54d7dffa2122e7ff6bcd5b6. 8/10: two P2 findings are detailed inline. The prior New chat recovery race is superseded by removing recovery. The ordinary-composer finding is an earlier review miss exposed by tracing its actual admission path; it was not introduced by the latest cleanup.

The explicit discard-on-refusal decision, dependency on block/buzz#8421 and catch/log setup-failure choice are preserved. The second finding concerns local failures before publication being treated as relay refusal. The existing limitations remain: genuine refusal loses sent content and the pending draft; setup failure may dispatch a turn without the intended Canvas/default agents; early local-agent work can precede relay acceptance.

Optional documentation follow-up: docs/solo/README.md:52–60 still describes the former start ordering, retained composer text after interruption, and setup that never holds the message. Update that bullet to describe create-member tags, setup gating and the chosen refusal loss.

Validation: immutable source, changed tests and caller/lifecycle tracing only; no PR code, tests, builds or app execution. One hosted-check snapshot showed WebKit 7/9 still running and Windows validation skipped; no CI-completion claim. Native/live-agent behavior, rendered focus/accessibility, cold/warm performance and human acceptance remain unverified. Public PR description inspected; no attached images or disclosure finding. This is a COMMENT review, not approval or merge authorization.

// A chat this device is still starting has nothing to read until the
// relay has its create; reading sooner would be refused as a denial.
const starts = [
...new Set(filters.flatMap((filter) => filter["#h"] ?? [])),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [P2] Wait for creation before refreshing the pending Solo chat’s roster

With creation delayed, submit a follow-up from the newly opened chat. ChannelsPage.tsx:1443–1473 mounts its enabled ordinary composer; MessageComposer.tsx:1283–1286 calls prepareRecipients, which unconditionally refreshes membership at line 1145. That reads kind 39002 using #d (channel-writes.ts:308–339), so this #h-only guard does not wait for creation. An empty pre-create roster response throws “Could not refresh channel membership. Retry to continue.” The first message later sends, but the follow-up stays unsent until another user submission. Its text is retained.

Wait for this channel’s start before that membership refresh, retaining the fresh signed-roster admission check. Cover the mounted ordinary composer with creation held: the current multi-message test (solo-start.test.ts:306–329) calls session.messages.send directly and bypasses this path. Source-traced, not runtime-reproduced.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 21f19f4. refreshMembership now waits for a chat this device is starting before it reads the roster, so every caller waits for the create: the composer's prepareRecipients, Canvas preparePublish, and addAgents. The signed-roster check is unchanged. New test in channel-writes.test.ts: "reads a starting chat's roster only once the relay has its create". It fails if the wait is removed.

Comment thread src/features/relay/channel-writes.ts Outdated
try {
return await delivered(create);
} catch (error) {
if (api.failed(create))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [P2] Keep local pre-publish failures distinct from relay refusal

api.failed(create) checks only delivery === "failed" (lines 703–709). That also describes an attempt that fails or times out while signing/persisting, before writer.publish is entered (outbox.ts:479–506, 528–532, 549–578). On the first attempt, delivered() rejects when that state appears; this branch immediately throws ChatNotStarted, bypassing the retry/backoff and strong-read paths below. The new cleanup then withdraws the create and all messages, while SoloDraft clears the pending draft. A transient local signing timeout can therefore permanently discard the prompt and attachments without any relay rejection or completed reconciliation; its “check or retry this message” error has no surviving message to retry.

Preserve an explicit refusal classification and let retryable pre-publish failures retry the same create or remain recoverable. Keep the chosen discard behavior for genuine refusal/confirmed absence. Add coverage where signing fails or times out once, then succeeds, alongside the existing explicit-refusal case. Source-traced, not runtime-reproduced.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 21f19f4. The outbox now marks an operation refused only when the attempt ended in PublishRejected and its delivery is failed. A retry clears the mark, and it is persisted. created throws ChatNotStarted only for refused. A failure on this device (signing, persisting, or a timeout before publish) goes through the existing retry path for the same create. After three tries it is dropped only if the strong read finds nothing. New test: "retries a create that failed on this device before the relay saw it". It fails with the old failed check. The refusal test now marks the item refused, and the outbox retry test checks that the mark is set and then cleared.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Follow-up in bd7b021. I added a new PublishNotSent subclass of PublishRejected, so writes are still definitely unsent and keep the failed delivery state. The transport now raises it for local ApiPaused/ApiNotSent, for HTTP sent:false/API-quota answers, and for an OK false whose message starts with rate-limited:. The outbox's refused mark skips it, so a start in cooldown retries the same create. A 409, a 4xx, or any other accepted:false still counts as a refusal.

Coverage:

  • signed-admission.test.ts: the cooldown and the explicit quota answer both reject as PublishNotSent.
  • outbox.test.ts: a PublishNotSent failure is failed and not refused.
  • solo-start.test.ts, real session/outbox/start: "a create this device held back for the relay's cooldown goes again, and the chat starts". It sends the create twice, then the message, and the chat stays listed. It fails if the outbox marks PublishNotSent as refused.

A workspace's defaults load when it is picked. Its agents join in the
create with the tagged ones, the chat files under the workspace at once,
and local agents read the chat's Canvas from the start while its save runs
in the background. Nothing waits on setup any more, so the settling gate
and applyChatSetup go.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account.

Reviewed head f0798820b3ffae4e410dbf697d7f8e5f8b3a3da2, base b2787b2a3da9c9a0c2b3b8f9b504b51959bad222, focusing on the 14-file follow-up since fb271258eb3ca8361ade4fe153d799af2dc413c7 and its supported callers.

8/10: one new P2 inline—preloaded workspace defaults survive a subsequent settings save, so the next chat can receive superseded Canvas instructions and default agents. Invalidate the cached setup when its source changes, retaining the fast path for unchanged defaults.

The two findings in the previous review remain unchanged by this commit: follow-up membership reads bypass the pending-create wait, and local pre-publish failures can be classified as refusal. Their original inline comments remain the single discussion locations.

Optional documentation: update docs/solo/README.md:51–60 to describe defaults loading before creation, default agents joining in the create, early workspace filing, background Canvas save, and retained-draft retry when defaults cannot load. The stated non-local first-turn Canvas limitation and chosen background-save/refusal behavior are preserved as product scope.

Coverage: Rocket systems/lifecycle/performance, Mantis correctness/security/concurrency, Groot product/accessibility/error-retry, integrated by Star Lord. Reviewed committed source and test changes, supported local harness callers, settings-save integration, public description and follow-up commit. No attached images or disclosure finding. Existing composer, workspace control, Dialog and inline alert owners remain; no rendered design validation performed.

Source-only: no PR code, tests, builds or app executed; current CI not assessed. Native/live relay behavior, cold/warm timing, visible pending feedback, keyboard/focus/assistive-technology behavior and human acceptance remain unverified. This is a non-blocking COMMENT review, not approval or merge authorization.

undefined,
);
useEffect(() => {
if (!selectedProject || workspace.current?.id === selectedProject) return;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [P2] Invalidate preloaded defaults after workspace settings change

Leave New chat open with a workspace selected, then edit that workspace’s Canvas/default agents through its sidebar Settings and save. SoloWorkspaces.tsx:271,478–484 opens that dialog without changing the draft route; WorkspaceSettings.tsx:216–224 saves the new template to channelKit. The draft remains keyed by project/agent (ChannelsPage.tsx:1550). This effect still returns for the same project, so the successful promise captured before the save is reused at lines 193–198. The next send creates the chat with the old default agents and saves/hands local agents the old Canvas (lines 210,244–251), despite the successful settings update. The prior head loaded setup at send time.

Invalidate or replace the cached setup when its underlying workspace/template revision changes, and only consume a preload that matches the current revision. Keep the unchanged-default fast path. Add a mounted-draft case that saves changed workspace defaults before sending and asserts the create’s members and initial Canvas use the new values. Source-traced, not runtime-reproduced.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 21f19f4. The preload stores the channel-kit revision it read (its entries' event ids). A start uses the preload only if that revision is still current, and otherwise loads at send time as before. If nothing changed, the fast path stays. New SoloDraft test: "a workspace's defaults changed while New chat is open start the chat". It changes the template after the preload and checks that the new Canvas is saved. It fails without the revision check.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Follow-up in bd7b021. loadChatSetup now returns revision (defaultsRevision, the kit entries' event ids), computed from the same kit snapshot whose template it uses, before the template-id and agent-choice awaits. SoloDraft compares that against the current snapshot when you send, and loads again if they differ. New workspace.test.ts case: "names the saved defaults it read, even when they change while agents load". The kit changes during agentChoices.refresh, and the result is the old Canvas with the old revision. It fails if the revision is stamped after loading.

…ps it

- A starting chat's membership refresh waits for its create, so a second
  message sent while the chat starts no longer fails its roster check.
- The outbox marks an attempt the relay refused. Only that ends a start;
  a failure on this device before the create was sent retries it.
- Preloaded workspace defaults carry the kit revision they were read at;
  a start reloads them if the saved defaults have changed since.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket, Mantis and Groot reviews.

Reviewed head 21f19f4a5388f0d464fb89f5c02372d06aa88393, base b2787b2a3da9c9a0c2b3b8f9b504b51959bad222, focusing on the seven-file follow-up since f0798820b3ffae4e410dbf697d7f8e5f8b3a3da2 and its supported callers. 8/10: two previous P2 repairs remain incomplete. The existing discussions remain the single inline locations; the new evidence is below.

  • Local failure versus refusal: signing failures now retry, but outbox.ts:572–575 marks every failed PublishRejected as refused. The supported native writer wraps connectSignedTransport (native.ts:342–348,805), whose transport.ts:1357–1358 converts local ApiPaused/ApiNotSent into that class. After an unrelated read starts the shared API cooldown, a new chat’s create can therefore be rejected locally without a request reaching the relay, yet immediately abandon the chat and withdraw its queued content. Distinguish actual relay refusal from retryable local non-dispatch while preserving definitive non-delivery semantics. Cover cooldown → same create succeeds through the real transport/outbox/start path; signed-admission.test.ts:17–78 already documents the local cooldown classification.
  • Workspace-default invalidation: saves after a completed preload now invalidate it, but SoloDraft.tsx:159–162 stamps the revision after loadChatSetup finishes. That loader captures the kit/template before awaiting template-ID calculation and agent-choice refresh (workspace.ts:267–295). Saving workspace defaults while that refresh is pending replaces the kit snapshot; the completed preload then pairs old Canvas/agents with the new revision. The send-time equality check accepts the stale setup. Return a revision derived from the same kit snapshot actually consumed, and cover a settings save while the preload’s agent refresh is held.

The pending-chat membership fix is present: refreshMembership now waits for the start before reading the signed roster. Preserve the stated relay dependency, confirmed-absence/refusal cleanup, background Canvas-save behavior and non-local first-turn Canvas limitation. The previous optional documentation note remains; no new design or public-material finding. Public description and follow-up commit inspected; no attached images.

Source-only review: no PR code, tests, builds or app execution; CI not assessed. Native/live relay behavior, cold/warm timing, rendered keyboard/focus/accessibility, themes/responsiveness and human acceptance remain unverified. Existing composer, workspace control and inline alert owners are retained. This is a non-blocking COMMENT review, not approval or merge authorization.

…read

- PublishNotSent (a PublishRejected) marks writes proven unsent but not
  refused: the API cooldown or queue held them back here, or the relay
  rate-limited them. The outbox's refused mark skips them, so a start
  retries the same create instead of dropping the chat.
- loadChatSetup returns the revision of the kit snapshot it actually
  read, so defaults saved while agents load cannot pass as current.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen
baxen merged commit 70a3e5e into main Oct 20, 2026
32 checks passed
@baxen
baxen deleted the mbot/solo-instant-start branch October 20, 2026 03:27
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.

3 participants