Skip to content

fix(harness): settle delayed ACP tails - #1083

Closed
niiyeboah wants to merge 264 commits into
mainfrom
agt_7c035a627215/agent-627215
Closed

niiyeboah wants to merge 264 commits into
mainfrom
agt_7c035a627215/agent-627215

Conversation

@niiyeboah

Copy link
Copy Markdown
Collaborator

Summary

  • Treat ACP prompt responses as the authoritative turn boundary.
  • Keep delayed post-response notifications as closed-turn transport tail, eliminating phantom active turns and unusable Stop controls.
  • Cover delayed-tail queue behavior and strict completion semantics.

Validation

  • pnpm run check passes.
  • Focused supervisor tests pass in reviewer environment.
  • Local full unit/E2E suites require Docker Compose, which is unavailable in this worktree environment.

Product decision

Strict ACP completion selected: unmarked post-prompt goal rounds require an explicit adapter lifecycle signal.

niiyeboah and others added 30 commits September 4, 2026 13:17
Driver: an error listener on the child before any await, a bounded ACP
handshake, a PATH pre-flight naming the missing binary, fallback to a
new session when the stored one cannot resume, an expected flag on exit
events so a requested stop is not reported as a crash, and a permission
fallback that cancels rather than throws.

Supervisor: one turn at a time per agent with held semantics for prompts
that land mid-turn, a denylist child environment that keeps the login
shell's SSH/proxy/token vars and adds the TLS CA and Dispatch vars,
stopAll at shutdown, restoreRunning at boot, and overlay removal on stop.

Recorder: per-row size bounds with a truncated flag, coalesced chunk
writes, and tool paths relative to the agent's cwd.

Prompt dispatch moves into AgentManager.getPromptTarget/promptDsh, launch
guidance inputs share one helper, extractAppendedSystemPrompt replaces the
codex-branch trick, payload types live with the store, the chat envelope
lookup folds into delivery, dispatchMcpUrl takes only what it reads, and
the unused StreamStore.latest is gone.

dsh is opt-in (DEFAULT_ENABLED_AGENT_TYPES), documented in the runbook,
with an update-migrations manifest for the release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Activity rows no longer swallow the agent's header: layoutFeed leaves
lastPost untouched for them, and the first row of a run renders inside a
Post with the agent's avatar. Streaming rows version by text length so the
pane keeps following as a reply grows, without flickering the new-messages
pill. The diff is a line-aligned LCS with coloured +/- rows and treats a
null old text as an empty file. Activity rows are buttons only when
expandable, announce title and state, pulse while in progress, and mark a
failure with an icon and colour; paths show relative and keep the title
readable. The writing indicator is a labelled status row that respects
reduced motion. Truncated rows say so.

Also: the turn chain survives a rejected turn, a spawn failure reports the
child's exit reason over the aborted handshake, and per-row writes are
chained so a timer flush cannot land after close. The unread gap for
stream entries is recorded in the spec.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
entryVersion keys the enter animation and must not change per chunk; the
pane's follow logic keys on a separate entryGrowthKey that adds text length
and status. DiffBlock memoizes its alignment across feed refetches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Activity rows group under the previous agent row (post or activity); the
assistant post after a run still opens its own group.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e default model

restoreRunning ran inside initializeApp, before app.listen, so a resumed
harness could not attach Dispatch's MCP endpoint and every restore failed.
It now runs from start() after listen. A fresh dsh session receives the
agent's launch-context post as its first turn (dsh takes no launch
argument). With no model chosen, the overlay picks a model whose provider
key the service has instead of the profile's DeepSeek default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The create dialog offers the model picker for dsh; the Agent pane opens on
Chat for a dsh agent whether or not the global chat-surface flag is on,
quick prompts route through Chat for it, its launch post is durable, and
its launch guidance assumes Chat. The type icon is the fish, dsh's own
mark, instead of a text badge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The launch guidance for dsh drops the pane-era chat rule (which sent every
reply through dispatch_chat_post, so the first live turn answered twice)
and states that replies stream into Chat, with dispatch_chat_post only for
a question with options. dsh's ACP tool calls carry no kind, so the
recorder infers one from the tool name. dsh reports no token counts on the
wire; recorded in the spec with the harvester fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…Dispatch tokens

Contracts, pure reducer, formatting helpers and the braille stream ticker
come over from @mytraai/promptkit trimmed to what dsh emits. The six
primitives (PromptLine, StepRow, StepDetail, ActivityBlock, ResultTurn,
TurnStream) are rewritten on shadcn and Dispatch's status tokens, with a
module registry in place of the React context. Tailwind gains the three
harness keyframes.

Also rebrands the agent type: 'Dispatch' in menus, 'Dispatch Harness' in
settings, and the Dispatch brand mark in place of the fish icon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pane

The turn stream (PromptKit port) becomes the default view for a Dispatch
Harness agent, with Chat and Console behind a three-segment toggle. Turns
come from the harness turns route, refresh on chat.changed, and prompts go
through the chat composer so cross-agent messaging is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…on it

The stored pane view now starts as null so a Dispatch Harness agent opens
on the Harness view instead of the atom's old "chat" default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ersion

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ches

Turn settles reach the web as agent events, not chat.changed, so the
Harness view sat on a stale trace until a reload. Invalidating alongside
the feed covers agent upserts, reconnects, reviews and messages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…r dsh skills

Steps keep the tool call's raw input (dsh sends it), so an execute step
reads as its command, a read step as its file, and an MCP tool as its
name with a digest of its arguments; steps with nothing underneath are
no longer expandable. Locations fall back to the input or read wrapper.

Dispatch Harness agents lose the redundant Chat segment: the toggle is
Harness | Console and a stored Chat preference lands on Harness.

Typing / in the Harness composer opens a picker over the skills dsh can
load (project .agents/skills, .dsh/skills, and the harness home), served
by GET /api/v1/agents/:id/harness/skills; the persona tells the agent a
leading /<name> means load that skill.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g, keyed providers only

dsh advertises session config options (a provider-grouped model select and
a reasoning-effort select) over the Agent Client Protocol. The driver keeps
them per session and can set one; the supervisor persists a model switch
on the agent and drops provider groups whose API key the service lacks.
The create dialog's dsh list now comes from a cached probe session, so
every model dsh serves (gpt-5.6-sol, terra, …) is offered; dsh model ids
validate by shape. The Harness pane shows a model · effort chip that opens
the picker; /model in the composer opens it too.

Also stops the turn stream snapping to the bottom on refetch: it follows
only while the reader is at the end.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ugh the picker

Radix Select refuses an item whose value is ""; dsh's reasoning-effort
list opens with one. The picker holds values under a stand-in and decodes
them at the edges.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A persona launch (and an MCP launch) hands the kickoff as initialPrompt,
which CLI agents type into the pane. A dsh agent's first turn is read
from the Chat launch post, which only carried the create dialog's context
prompt, so persona reviews on Dispatch Harness agents started with no
brief and sat idle. The launch post now falls back to initialPrompt for
dsh agents.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…6-only catalog

Step details now format what they show: read output becomes a highlighted
code block with a line-number gutter, path lists render as paths, JSON
tool output and arguments pretty-print with highlighting, and shell
output stays plain. Prompts Dispatch injects (review thread updates,
persona kickoffs, the rename nudge) render as a one-line notice with the
full block on demand instead of as something the user typed.

The harness agent's view segment reads "Chat". The dsh model catalog
carries the provider as a group for section headers, and OpenAI offers
only the gpt-5.6 generation (plus whatever a session is already on).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
niiyeboah and others added 29 commits September 9, 2026 17:43
The release notes, the runbook and one line of Settings copy described a
Harness view and an Agent-types opt-in, neither of which ships: a
dispatch agent reads in the Chat feed, behind the new Dispatch Harness
setting.

Four more references outrun the plan's list. Three name a view that is
deleted rather than moved, in the release notes' motion bullet and in
two doc comments; the fourth, on the publishHarness hook, still promised
that this plan would drop the coarse event, which it narrowed instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The turn payload's prompt path was spelled twice, once in TypeScript off
TurnPayload and once as a SQL literal in the feed's anti-join. Only one
of them is compiler-checked, and a rename would have left every chat
prompt rendering twice, so the path now has one owner beside its
TypeScript reader.

HarnessQuestion had no reader outside the server's assembler and no wire
message carried it, so it moves next to AssembledTurn as
AssembledQuestion rather than sitting in the client/server contract
package inviting a client to import it.

ResultTurn's showTime and isStreaming were both unreachable: its one
caller pins showTime false and never passes isStreaming, and the
streaming placeholder went with the turn stream.

useHarnessQueued and useHarnessQueue differed by one character in the
same module and were called on adjacent lines; the query is
useQueuedPrompts now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three reviewers found the anti-join shipping without an index. Nothing
covered kind or the prompt path, so Postgres answered it with a
sequential scan that was not even restricted to the agent: one agent's
feed page slowed down as a different agent accumulated rows. Measured on
a synthetic 60k-row agent, 64.6ms and 8576 buffers become 2.5ms and 413.

Two reviewers found the publish racing itself. Each recorder flush
composes the whole open turn, so two composes overlap on a slow disk and
the older one can publish last, leaving a turn short and still marked
streaming. Nothing corrected it, because that turn's own event no longer
refetches the feed and the flush that settled it was the last one. There
is now one compose per agent at a time with a single trailing re-run,
and the client ignores a turn whose updatedAt is older than the row it
already holds.

A prerelease install also lost its harness opt-in on update, silently:
the type was turned on by adding dispatch to enabled_agent_types, which
is now stripped on read, and a running dispatch parent's persona launch
would start throwing mid-turn. A migration carries the old choice onto
the new flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A turn is anchored where it started, so the status event an agent emits
mid-turn becomes the tail entry while the turn keeps growing. Following
read growth off the tail alone, so a reader pinned to the bottom drifted
off it the moment the agent reported its first phase, which CLAUDE.md
has every agent do.

ArrowUp took the newest queued prompt whatever its source, so a child
agent's message queued behind a running turn could be deleted
undelivered and land in the user's draft to be re-sent as their own. It
takes the newest chat-sourced one now, the same rule the history walk
beside it already used.

Template launches and job runs read an agent type straight from their
request body and reached createAgent without consulting the offered
list, so dispatch agents were creatable with the harness flag off. The
gate moves to createAgent, which every creation path goes through,
before the media directory is made.

Two E2E defects: both harness specs left a server-wide flag on, which
the settings assertion only survived by file ordering, and the turn
seeder's seq arithmetic double-counted, writing 1, 3, 6, 10 where the
recorder writes 1, 2, 3, 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Folding the harness into the chat pane handed a dispatch agent the
unread badge, the child-agent filter and the copy action. None of the
three could see a turn, which is the only thing that type produces.

Unread counted chat rows, and a harness answer is never one, so the
badge could fire only for a question the agent asked. Turns carry no
per-row read state, so agents gain a chat_read_at watermark: a full
mark-read moves it, and both the pane count and the sidebar summary now
add the turns that settled after it. Existing rows are stamped at
migration time so a backlog does not light up every badge at once.

The child filter matched agent_message entries only, so hiding a child
conversation hid the outbound half and left the turn the child's message
drove. ChatTurnPrompt carries senderAgentId now, which the server had
already parsed and thrown away.

A cross-agent message rendered twice: once as its own row, once as the
turn's prompt, in two cards that disagreed, and minutes apart whenever
the prompt had queued. The turn stops drawing its own copy; the row that
carries the sender's badge and id is the single rendering.

Also: the turn's answer gets the copy button its prompt always had, the
empty state stops telling a dispatch user to look in a Console its agent
never writes to, the status line stops repeating the sentence the
composer prints under the field, and the queue hint says which message
ArrowUp takes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three fixes the reviewers found in the batch before this one.

The read watermark never moved. It was gated on an unbounded read, but
the pane always sends a bound (the newest agent message it holds), so
the gate never fired and a settled turn stayed unread forever. Every
read moves it now, and it moves to now rather than to the bound: on a
harness agent that bound can be far older than the turns under it, so
honouring it would leave them unread just as permanently. A route test
covers the path the pane actually takes; the store test did not.

A settled turn also invalidated nothing, so the badge waited for a
refocus to appear. The chat.entry handler counts one now.

Filtering child agents hid a child-started turn, which was an overreach
once the turn stopped rendering the child's words: what is left is the
parent's own rail and answer, no other agent type's filter hides the
agent's own output, and an agent driven entirely by children would show
an empty pane. Reverted; senderAgentId stays on the wire.

Composing a turn entry reads the whole open turn and the recorder asks
about ten times a second, so a server with no browser attached was
paying for an announcement nobody received. It skips when no client is
connected; a reconnecting one refetches from the rows anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ess-provider-auth-ux

# Conflicts:
#	apps/browser-extension/package.json
#	apps/browser-extension/public/manifest.json
#	apps/server/package.json
#	apps/server/src/chat/service.ts
#	apps/server/src/chat/store.ts
#	apps/server/test/chat-service.test.ts
#	apps/web/package.json
#	apps/web/src/components/app/chat/chat-feed.test.tsx
#	package.json
#	packages/shared/package.json
#	release-notes/current.md
An install that ran the v0.38.14 release has 0051_agent-chat-reactions in
pgmigrations where this branch ships 0051_agent-stream-events. node-pg-migrate
compares the stored names against the shipped files position by position, so
the server threw before the first migration ran and every start after the
update crash-looped. The record is dead bookkeeping: this branch ships the same
table as 0057_agent-chat-reactions, and that file is guarded with IF NOT
EXISTS, so re-applying it on top of the existing table is a no-op.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…resence

Every dispatch_event landed twice: as a row in the feed and, in the same
words, in the presence line above the composer. The row was the duplicate,
and each one moved the reader's tail and fired the follow logic for content
that said nothing new. Status entries now lay out no row at all; the server
still emits them and the presence line still shows the latest one.

The presence line also moves above the harness chrome, so what the agent is
doing now reads before the model, auth and usage chips, which are controls
rather than news. Non-harness agents see no change: their chrome is null.

Removed with it: collapseFeed, StatusLine and the status cluster in ChatFeed,
which had no other callers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The feed followed the bottom with an instant scrollTo after each render, so
every change in the live turn's size was a snap: a step row landing, the
thinking row appearing after its 500ms delay and vanishing on the next tool
call, a streamed line, and the largest one, the rail folding from its full
height to a one-line summary on settle. Depending on the size of the
answer the whole feed jumped by that much at once.

The turn's rail and answer now sit in one AutoHeight body, a wrapper that
measures its content with a ResizeObserver and animates its height between
sizes on the harness's standard curve (instant under reduced motion). The
pane pins the bottom through those frames with a ResizeObserver on the feed
content while the reader is following, so older rows glide upward instead
of the reader chasing the bottom. Measured on a live turn at 50ms: the
distance from the bottom stayed at 0px throughout, and the settle collapsed
180 to 74px across four samples rather than one.

Without a ResizeObserver the wrapper stays auto and the content sizes
itself as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A pass over the whole branch for code and comments that carry no weight.

Server: unused types and a struct field that was never read
(StatusPayload, StreamPayloadByKind, Live.stderrTail), exports nothing
imports (TERMINAL_OUTPUT_MAX_BYTES, AUTONOMOUS_IDLE_MS, modelOptionOf,
isUsageEngineId, HARNESS_MODEL_HALF, the feed limit re-exports,
SOURCE_RANK, locationsFromInput), the three unreachable 503 branches on
the harness usage routes (registerSystemRoutes has one caller and it
always wires them), and the chatSurface flag buildHarnessPersona took
and never read.

Web: the PromptKit-parity fields the server never emits (retry and
skipped step statuses, clarification results, attempts, context chips,
error hints, prompt attachments) and the PromptLine branch that rendered
a typed prompt, which the turn entry never reaches since typed prompts
render as the user's own post. The ungrouped AgentModelSelect path no
longer carries the group rendering the grouped early return already
handles. Two dead exports in chat-composer.

Tests and fixtures: an unused stderr handle on the fake ACP agent, a
duplicated constant, stderr traces in the E2E fake, and comments that
named review rounds or people instead of behavior.

The assisted-update manifest now counts the migrations this release
ships and lists the v0.38.14 reactions record the runner forgets at boot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@niiyeboah niiyeboah closed this Sep 12, 2026
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