Skip to content

Let a person's message to chopin stop a background job instead of being refused - #886

Merged
lavaman131 merged 1 commit into
mainfrom
message-preempts-job
Nov 13, 2026
Merged

lavaman131 merged 1 commit into
mainfrom
message-preempts-job

Conversation

@lavaman131

Copy link
Copy Markdown
Collaborator

A person's message to chopin goes ahead of a background job

What the maintainer saw

While chopin was keeping the document's title, or doing another background job, a message to chopin (an @chopin message, or any message from someone alone in the document) was refused: "chopin can't take messages while it works on this machine. Stop it to ask now." The draft came back to the composer. Every member message asks for a title job, so this happened right after almost every reply chopin gave.

Before (origin/main) After (this branch)
Before: the message is refused and returned to the draft After: the message is sent and chopin's turn has started

Both shots come from the same browser spec, taken 1.5 s after the second message while the title job was running.

Cause

A background job's turn is isolated and takes no steering, so processSend refused any message that arrived while one ran (planner-busy). invoke_planner messages were refused the same way. The job held the Chat until it finished.

The fix

A background job now gives way to a person (preemptible and preempt in apps/server/src/chat/service.ts).

  • The message is accepted at once. It is saved, acknowledged and shown. The job's turn is stopped, and the person's turn starts as soon as the job's turn has wound down, ahead of anything waiting. A job that is about to be handed the Chat (its handoff save is pending) is put back to wait behind the person's turn instead.
  • Only background jobs give way. A turn a person started is never preempted; a message sent during it is still given to it (steering is unchanged). Turns Chopin starts for something a person did (a Build, a comment, a decision follow-up instruction) are not jobs and are never preempted.
  • No half-written results. Each job writes only through its own tool (draft_heading, write_decision_prose, write_decision_scope, refine_decision), under the document lock. A write already under way when the person asks is allowed to finish (chat.jobWriting), so it lands whole and counts as the job's result (done, not run again). Once a job is giving way, no new write may start.
  • The job runs again later. With no write made, the job reports a new outcome, preempted. Jobs.settle puts it back to pending with its attempts unspent. The queue picks it up when the person's turn ends (that end already wakes owner-bound work through ownerAvailable). If a newer request for the same thing is already waiting (a newer title request, or a newer refine of the same card), the one that gave way is skipped with "A newer request replaced it." This keeps the stored queue valid, because it allows at most one pending title job.
  • It still finishes if people keep talking. One job (kind and target) gives way at most three times in a row. The next run is allowed to finish, and the person's message is still accepted at once; their turn starts the moment the job ends. The count resets when the job finishes.
  • invoke_planner messages preempt in the same way. as: "system" follow-ups still need a turn of their own and are unchanged.
  • A process that is stopping does not start a person's turn in place of a job. It says nothing, so the browser sends the message again to the next process, as for an idle Chat.
  • The Copilot SDK harness cannot take input mid-run, so it still refuses messages sent during a person's turn. A message sent during a job's turn now preempts the job on every harness, this one included.

AGENTS.md, docs/hosted-agent.md (Messages while the Planner works), docs/local-agent-mcp.md (planner-busy) and the composer's Stop paragraph in apps/web/DESIGN.md say so.

With quiet turns (#after-answer)

A background job's turn is sent as quiet. Chat draws no working row for it and offers Stop only after a message was refused because of it. That is the turn a person's message now preempts.

  • While the job runs: there is no row and no Stop, as before.
  • While it is being stopped: the person's message is accepted, not refused, so Stop does not appear for the job. The transcript shows the person's message, and the job's quiet turn stays undrawn for the ~20 ms until it ends.
  • The person's turn: an ordinary watched turn (no quiet), with its working row and Stop.
  • When the job runs again: its turn is quiet again.

The "Stop after a refusal" path still applies to a reply's quiet after-answer check on a harness that takes no input mid-run. The unit tests check quiet on the job's turn, the person's turn and the job's re-run. The browser spec checks that there is no Stop and no "Getting oriented" row while the title job runs and after it runs again. chat-after-answer.e2e.ts still passes: its last test scripts a refusal during a quiet job turn on the client side. That case still exercises the client rule, though this server no longer refuses a message because of a job.

Measurement: time from send to the Planner receiving the person's turn while a title job runs

Unit fake harness. The title job takes 3,000 ms unless stopped and each save takes 10 ms; 5 rounds. On main the send is refused, so the script sends again every 50 ms, which is the best a person could do.

median samples (ms) refused sends
origin/main 3,023 ms (the rest of the title job) 3022.0, 3022.3, 3023.1, 3023.4, 3040.5 300 (60 per round)
this branch 20.4 ms (two saves: the message, then the job's turn end) 20.4, 20.4, 20.4, 20.7, 24.4 0

Browser, real server. HARNESS=remote, PostgreSQL, and a scripted machine whose title job keeps working until it is stopped. From pressing Enter to the machine receiving the person's turn took 163 ms on this branch. On main the spec times out after 10 s with no turn started; in a real session the person would have to wait for the title job to finish and send again.

Tests

New, and failing on origin/main (all 9 unit tests fail there; the browser spec times out):

  • apps/server/src/chat/preempt.test.ts (7 tests). The person's turn starts at once and the job reports preempted. A job whose tool already wrote is done and not run again. A write already under way finishes whole and is the job's result, and no new write starts. Three preemptions, then the job finishes with the person's turn right behind it, and the count resets. A job being handed the Chat waits behind the person's turn. The Copilot SDK harness preempts as well. An invoke_planner message preempts and settles when its turn ends.
  • apps/server/src/conversation-plan/planner-jobs-preemption.test.ts (2 tests). A preempted job goes back to pending with attempts unspent and completes when woken. A preempted title job that a newer one replaces is skipped, and the stored queue still restores.
  • e2e/steering.e2e.ts: "a message sent while Chopin writes the title starts its turn at once: the title waits and is written after". It records the timing above and checks that the title job ran again after the person's turn.

Updated, because they asserted the old refusal: steering.test.ts ("a background job's turn takes no message: the person's own turn starts in its place") and job-queue.test.ts (the person's turn after preemption has no job scope).

Checks run

  • bun run types: passed
  • bun run ci: passed
  • bun test apps/server/src/chat apps/server/src/conversation-plan apps/server/src/agent apps/server/src/mcp: 2,568 passed, 0 failed (1 skipped) across 332 files
  • Playwright (--workers=2): steering, composer-send-stop, conversation-initiative, harness, harness-children, harness-comments, harness-decisions-deck, harness-investigation: 64 passed
  • The new unit tests and browser spec run against origin/main 4b1e6c6: all fail, as expected
  • After rebasing onto origin/main 74772ee (quiet turns, pasted-URL links): bun run types and bun run ci passed; bun test apps/server/src/chat apps/server/src/conversation-plan apps/server/src/agent passed (2,446 tests, 0 failed); Playwright steering, composer-send-stop, chat-after-answer and the five harness specs passed (57 tests; send to the person's turn took 112 ms). The only conflict was in docs/hosted-agent.md, where both sentences were kept.
  • After the earlier rebase onto origin/main df0fb43: bun run types and bun run ci passed, bun test apps/server/src/chat passed (1,015 passed, 0 failed), and Playwright steering plus composer-send-stop passed (19 passed; send to the person's turn took 139 ms)

Judgement calls

  • Three preemptions in a row, then the job finishes. This guarantees a job completes even in a busy conversation. The person is never refused; at worst their turn waits for the rest of one job run. A title job usually takes a few seconds. A time-based back-off would not guarantee completion.
  • Only preempt between writes. A write already under way finishes (it is in-process and quick under the document lock) rather than being cut off. This keeps decision write-ups and the title whole, and a finished write is never repeated.
  • preempted is a new outcome rather than reusing deferred. deferred means "wait for an owner" and is limited to decision follow-ups. Mixing the two would weaken that check.
  • A second message during the short wind-down (about 20 ms) is answered as a passing trouble on harnesses that steer. The browser sends it again with the same request id, and it is then given to the person's turn. On the Copilot SDK it is refused, as it would be during any person's turn.
  • Not changed: a comment note to chopin, Retry on a failed reply, and an as: "system" follow-up are still refused while any turn runs, a job's included. They are not messages, and changing them would widen the scope. They could use the same preempt path later.
  • No visual change, so only the before and after desktop Chat shots are included, with no phone or dark variants. The quiet row in the "before" shot is the existing refusal UI.

… of being refused

While chopin kept the document's title, or ran another background job, a message to it (an
@chopin message, or any message from someone alone in the document) was refused with "Stop it to
ask now", because a job's isolated turn takes no steering. Every member message asks for a title
job, so this followed almost every reply.

A message to the Planner, or an invoke_planner message, now preempts a background job. It is
saved and acknowledged at once, the job's turn is stopped, and the person's turn starts as soon
as the job's has wound down, ahead of anything waiting; a job being handed the Chat waits behind
it instead. A write the job's own tool has under way finishes first and stands as its result, and
no new write starts, so a title or a decision write-up is never half written or written twice.
Without a write, the job reports the new outcome preempted and goes back to pending with its
attempts unspent; the end of the person's turn wakes the queue and it runs again. A newer request
for the same thing that is already waiting replaces it. One job gives way at most three times in
a row; the next run is let finish with the person's turn right behind it, so it still completes.
Turns a person started keep taking steering, and are never preempted.

A job's turn is quiet (Turn.quiet): Chat draws no working row and offers no Stop for it, and since
a message during it is no longer refused, Stop never appears for it now. The person's turn that
takes its place is an ordinary watched turn; the job's run after it is quiet again.

Measured with the unit fake harness (a 3 s title job, 10 ms saves), the time from send to the
Planner receiving the person's turn went from 3,023 ms (refused until the job ended) to 20.4 ms;
in the browser against the real server with a remote machine it was 163 ms.

Assistant-model: Claude Opus 5.5
Assistant-workflow: inline (subagent)
Assistant-verification: bun run types
Assistant-verification: bun run ci
Assistant-verification: bun test apps/server/src/chat apps/server/src/conversation-plan apps/server/src/agent apps/server/src/mcp (2568 pass, 0 fail)
Assistant-verification: playwright steering, composer-send-stop, conversation-initiative, harness, harness-children, harness-comments, harness-decisions-deck, harness-investigation (64 passed)
Assistant-verification: new unit tests and browser spec fail on origin/main 4b1e6c6
Assistant-verification: after rebasing onto 74772ee: bun run types; bun run ci; bun test apps/server/src/chat apps/server/src/conversation-plan apps/server/src/agent (2446 pass); playwright steering, composer-send-stop, chat-after-answer, harness, harness-children, harness-comments, harness-decisions-deck, harness-investigation (57 passed)
User-preference: a person's message to chopin must never be refused because a background job is running
Co-authored-by: Alex Lavaee <lavaman131@github.com>
@lavaman131
lavaman131 merged commit db9f1b0 into main Nov 13, 2026
46 of 48 checks passed
@lavaman131
lavaman131 deleted the message-preempts-job branch November 13, 2026 01:17
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