Skip to content

Run the Planner in a client attached over a Planner link - #468

Merged
lavaman131 merged 10 commits into
mainfrom
remote-planner-link
Oct 11, 2026
Merged

lavaman131 merged 10 commits into
mainfrom
remote-planner-link

Conversation

@lavaman131

@lavaman131 lavaman131 commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Adds HARNESS=remote: the Planner's model turns run in a client attached to the document over an authenticated WebSocket at /planner-link, usually a member's own Atomic session on their machine. The server keeps the document, Decisions, room writes and anchors; the client keeps its code, model credentials and its own tools.

  • Protocol (packages/planner-link, docs/planner-link.md): versioned, schema-validated JSON messages of at most 2 MiB, documented as a complete wire format for clients that cannot import Chopin packages. Covers attach, start, turns with abort, streamed parts, host tool calls, input requests with answer/expiry/cancel, Stop/Resume, run cards, destroy and detach.
  • Endpoint (apps/server/src/harness/remote/endpoint.ts): the upgrade carries a GitHub bearer checked like hosted MCP (401/403/503 before upgrading); the bearer's account needs pull access to the document's repository, rechecked before every session and turn and every 5 minutes.
  • Adapter (HARNESS=remote): forwards start/turn/abort/destroy. Host tools still run in the server against the room. Client questions become Decisions through createHumanInput, with the in-process Planner's expiry, cancellation and paused-run hold. A turn ending or the link dropping stops its running host tools and withdraws their Decisions; a failed start withdraws questions it asked.
  • Config: HARNESS=remote is accepted on a non-loopback bind without HARNESS_AUTH or MODEL, since the model runs on the client.

Ownership (one Planner per document, planner-attached, planner-not-attached) is the stacked follow-up PR.

Verification

  • bun run types: passed.
  • GIT_CONFIG_GLOBAL=/dev/null bun test: 4642 pass, 4 skip, 0 fail (rebased on main with Atomic 0.9.33).
  • bun run ci: passed (0 errors).
  • harnessContract suite against HARNESS=remote through a test client that speaks only the documented wire format, over real WebSockets on Node 24: passed.
  • Real-WebSocket regressions for bad bearer, forbidden repository, malformed/oversized messages, disconnect mid-turn with a running host ask, failed start with an open question, and reused request/tool-call IDs.
  • Three review rounds by two reviewers plus two targeted re-reviews; the last review approved with no findings.
  • End-to-end against a deployed instance with an external Planner client written only from docs/planner-link.md: passed (summary in a comment on this PR).

Assistant-model: Claude Opus 5.5 (fast)
Assistant-workflow: hosted-planner-slices (runs 8cf1bc0a, repairs by worker subagents)
Assistant-verification: bun test passed: 4642 pass, 0 fail on main + this branch
Assistant-verification: harness contract passed: HARNESS=remote over real WebSockets
Co-authored-by: Alex Lavaee alexlavaee@microsoft.com

lavaman131 and others added 6 commits October 10, 2026 20:37
HARNESS=remote adds a HarnessV1 adapter whose sessions and turns run in a
client attached at /planner-link instead of in the server. The server keeps
the document, Decisions and room writes: Chopin's host tools still execute
here against the room, and only the model and the client's own tools run on
the client's machine.

The link is a versioned, schema-validated WebSocket protocol in the new
`@chopin/planner-link` package. Frames are strict JSON objects of at most
2 MiB; a malformed, out-of-order or oversized frame ends the link with an
error message and close code 1008 or 1009. docs/planner-link.md states the
whole wire format, and the package's minimal client uses only that format
at runtime, so the remote harness contract suite runs both in Bun and as a
separate Node.js process.

The upgrade request authenticates a GitHub token bearer exactly as hosted
MCP does (401, 403, 503 before upgrading), and attach requires pull access
to the document's repository; refusals post nothing. Access is rechecked
before every session. Client questions become Decisions through
createHumanInput, which now also reports whether a request was answered,
expired or cancelled, keeping the verbatim text, 30-minute expiry,
cancellation and paused-run hold of the in-process Atomic Planner. Run
cards, Stop and Resume travel over the link. A link that drops mid-turn
ends the turn with an error, withdraws its questions and shows its runs
stopped.

HARNESS=remote is accepted on a public bind without MODEL, refuses any
HARNESS_AUTH, and requires BACKGROUND_JOBS=off because summary and research
workers would have no model. Until one Planner per document is enforced,
the newest attachment serves new sessions.

Assistant-model: Claude Opus 5.5 (fast)
Assistant-workflow: hosted-planner-slices
Assistant-duration: 21m converged, estimated 2h
Assistant-verification: bun run types passed: every workspace package and e2e
Assistant-verification: bun test passed: 4621 pass, 4 skip, 0 fail (GIT_CONFIG_GLOBAL=/dev/null bun test)
Assistant-verification: harnessContract passed: remote harness over a real WebSocket with the Bun client and a Node.js 24 client process
Assistant-verification: bun run ci passed: dprint, oxlint (31 warnings, same as main), tokens, design contract, Impeccable
Assistant-verification: relative doc links passed: scripted check of paths and anchors in the changed docs
Co-authored-by: Alex Lavaee <alexlavaee@microsoft.com>
A remote Planner's run reports no longer start follow-up turns: the runs
are only the client's word, so a pull-only client could otherwise start
a turn no writer asked for. Its blocked or failed runs still get their
Chat notice.

The link rechecks admission and pull access before every turn, including
turns of a session kept for its runs, and every 5 minutes while
attached; a lost check closes the link with access-revoked. A host call
id may be used once per turn even after its call has finished, and a
host call for an ended session gets the documented error result. Every
host-tool error result is now { "error": "..." }, as HarnessAgent's own
are.

The package client settles a waiting host call as failed on abort,
destroy, turn end or close, asks nothing when its signal is already
aborted, settles requests made after the link closed instead of leaving
them pending, and cuts generated message and error text to the 4000
character wire limit.

docs/planner-link.md states the input-result value for every status and
method, the QuestionAnswer shapes, partial answers on cancellation, and
error output shapes; docs/architecture.md describes the remote Planner
and its link.

Assistant-model: Claude Opus 5.5 (fast)
Assistant-workflow: hosted-planner-slices
Assistant-duration: 8m converged, estimated 45m
Assistant-verification: bun run types passed: every workspace package and e2e
Assistant-verification: bun test passed: 4630 pass, 4 skip, 0 fail (GIT_CONFIG_GLOBAL=/dev/null bun test)
Assistant-verification: bun run ci passed: dprint, oxlint (31 warnings, same as main), tokens, design contract, Impeccable
Assistant-verification: client regression tests passed: the 4 new client tests fail against the previous client.ts and pass now
Co-authored-by: Alex Lavaee <alexlavaee@microsoft.com>
… results

A repository lookup that GitHub answers with 401 now counts as revoked access,
so the link sends access-revoked and closes even while admission is cached; an
outage still leaves it open. An oversized host-tool result becomes the same
error for the server's turn and the client. The client closes on an
unparseable server frame with 4400, which Node.js permits, and settles what
waits at once. /planner-link answers 404 when the server does not run the
remote Planner, and the wire docs state every field limit the schemas enforce.

Assistant-model: Claude Opus 5.5 (fast)
Assistant-workflow: hosted-planner-slices
Assistant-duration: 8m converged, estimated 30m
Assistant-verification: bun run types passed: every workspace package and e2e
Assistant-verification: bun test passed: 4636 pass, 4 skip, 0 fail (GIT_CONFIG_GLOBAL=/dev/null bun test)
Assistant-verification: bun run ci passed: dprint, oxlint (31 warnings, same as before), tokens, design contract
Assistant-verification: regression tests passed: the repository-401 and oversized-result tests fail against the previous link.ts/endpoint.ts; the Node.js v24.21.0 malformed-frame test fails with InvalidAccessError against close code 1008
Co-authored-by: Alex Lavaee <alexlavaee@microsoft.com>
…start fails

A remote turn that ended while one of Chopin's host tools still ran left
that tool waiting: the harness waits for outstanding host tools before it
settles the turn, and they ran only under the caller's signal. A link that
dropped during an `ask` therefore left the stream pending, Chat busy, and
the Decision open. Each remote turn now carries its own host tool signal,
which the session passes to the Planner's tools beside the caller's and
the turn aborts whenever it ends: on link loss, protocol violation,
detach, the client's turn-end, or destroy. The tool withdraws its cards
through the normal cancellation path, and a dropped link still ends the
turn with the disconnect error rather than as a stop.

A session refused with start-failed or not started in time was dropped
without withdrawing questions the client asked meanwhile. A failed start
now closes the session like destroy, so those Decisions are withdrawn and
the client's ask settles as cancelled.

Assistant-model: Claude Opus 5.5 (fast)
Assistant-workflow: hosted-planner-slices (run 8cf1bc0a) repair (worker subagent)
Assistant-verification: bun run types passed: every workspace package and e2e
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test passed: 4639 pass, 4 skip, 0 fail across 673 files
Assistant-verification: bun run ci passed: dprint, oxlint (31 warnings, 0 errors), tokens, design contract, Impeccable
Assistant-verification: new link tests failed before the fix (timeouts, open Decision) and pass after it
Co-authored-by: Alex Lavaee <alexlavaee@microsoft.com>
A Planner link forgot an input-request id once its question settled, and never
recorded an id it answered failed at once, so a client could reuse a completed
id and get a second result. The link now remembers every open id and the last
1,024 it received, and a reuse it remembers is a protocol violation that closes
the link with 1008.

Tool call ids had the same gap within a turn: a host call refused for an
unavailable tool did not record its id, and an own tool-call part could repeat
its id. Both now fail the turn as a repeated call.

Assistant-model: Claude Opus 5.5 (fast)
Assistant-workflow: hosted-planner-slices (run 8cf1bc0a) repair (worker subagent)
Assistant-verification: bun run types passed (exit 0)
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test passed (4642 pass, 4 skip, 0 fail)
Assistant-verification: bun run ci passed (exit 0, 0 lint errors)
Co-authored-by: Alex Lavaee <alexlavaee@microsoft.com>
The image build copied each workspace's manifest by name and missed
packages/planner-link, so `bun install --frozen-lockfile` refused the
pruned checkout and the image did not build. Copy its manifest and its
production node_modules like the other server workspaces.

Assistant-model: Claude Opus 5.5 (fast)
Assistant-workflow: inline
Assistant-verification: docker build passed: full image built locally from this commit
Assistant-verification: image smoke passed: `@chopin/planner-link`, its client and apps/server harness/remote/endpoint.ts import inside the runtime image
Assistant-verification: bun run ci passed
Co-authored-by: Alex Lavaee <alexlavaee@microsoft.com>
@lavaman131
lavaman131 marked this pull request as ready for review October 11, 2026 04:39
lavaman131 and others added 3 commits October 10, 2026 21:40
…hed requests

Under HARNESS=remote, attaching a client now makes its account the
document's Planner owner through the same generation-guarded claim every
owner takes. The owner record names the account's live browser login,
since Chopin's repository tools run with that login's GitHub App token and
never with the link's bearer; an account without one is refused with
sign-in-required (4401). Attaching needs the eligibility every Planner
owner already needs, instance admission plus push or administration
access, rather than the design's pull access, and the periodic recheck now
holds a link to that too.

A document has one Planner. Another attach while one is attached is
refused with planner-attached (4409), naming the holder's login. A detach
or dropped socket keeps the ownership for PLANNER_ATTACH_GRACE_MS (two
minutes by default) so the same account can reattach under the same
generation; then it is cleared under that generation. Attachments live in
the process, and startup already clears every owner reference.

Members, MCP callers and research requests no longer claim ownership under
this harness. An `@chopin` message or invoke_planner call for a document with
no attached client is refused with planner-not-attached before anything is
posted or queued; the message names the document URL and says to connect a
Planner client to it from a checkout of the repository. The
browser shows that text in full on the request, and MCP returns it beside
the code. With a client attached, both arrive as turns on its link, and
Stop and Resume Planner reach it. Other harnesses are unchanged.

Assistant-model: Claude Opus 5.5 (fast)
Assistant-workflow: hosted-planner-slices
Assistant-duration: 13m converged, estimated unmeasured
Assistant-verification: bun run types passed: every workspace package and e2e
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test passed: 4657 pass, 0 fail
Assistant-verification: bun run ci passed: dprint, oxlint (0 errors), tokens, design contract, Impeccable
Assistant-verification: PostgreSQL contract passed: bun test apps/server/src/storage/postgres against a disposable postgres:17-alpine on port 55432, 83 pass, 0 fail
Assistant-verification: mutation check passed: disabling the planner-not-attached refusal in processSend fails the new chat service test
Co-authored-by: Alex Lavaee <alexlavaee@microsoft.com>
An attachment trusted its in-memory link after the stored ownership under
it was gone. Signing the owner's browser login out, its expiry, or a
repository writer's Planner reset left the link attached, so `@chopin` and
invoke_planner failed later with planner-owner-unavailable instead of
planner-not-attached, and no one could attach until the old socket closed.

Ending a login now closes every link it owns with sign-in-required (4401)
and clears that ownership under its generation; a reset closes the link
with access-revoked (4403). Neither starts a grace period, so any eligible
account can attach at once. The checks before every start and turn, and the
periodic one, also close a link whose stored owner no longer names its
login and generation, or whose login expired without being revoked.

The documentation now limits planner-not-attached to `@chopin` and
invoke_planner, says how other Planner requests behave unattached, and
says model-backed research is unavailable under the remote Planner.

Assistant-model: Claude Opus 5.5 (fast)
Assistant-workflow: hosted-planner-slices
Assistant-duration: 6m converged, estimated 10m
Assistant-verification: bun run types passed: every workspace package and e2e
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test passed: 4663 pass, 0 fail
Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, Impeccable
Assistant-verification: mutation check passed: disabling sessionRevoked, ownerReset and verify fails all six new link and chat service tests
Co-authored-by: Alex Lavaee <alexlavaee@microsoft.com>
A grace reattach read the stored owner and then bound the hold it had
captured, without checking whether a Planner reset or the owner's sign-out
had released that hold during the read. A snapshot taken before the reset
could still match, so the client reported attached while the server had no
owner, refused attached turns, and kept an orphaned registry link.

Each attach now records the sign-ins it rests on while it runs. A reset of
its document or the end of one of those sign-ins marks it, and it binds only
if nothing marked it and its owner sign-in is still live after its last
await; otherwise it releases any ownership it claimed, under its generation,
and is refused with the code an attached link would be closed with.

The recheck of an attached link now asks whether the owner's sign-in is live
before comparing the stored owner. PostgreSQL and memory storage report an
expired sign-in's ownership as released, so an expiry that cleanup had not
yet seen closed the link with access-revoked (4403) instead of
sign-in-required (4401).

Assistant-model: Claude Opus 5.5 (fast)
Assistant-workflow: hosted-planner-slices
Assistant-duration: 9m converged, estimated 15m
Assistant-verification: bun run types passed: every workspace package and e2e
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test passed: 4668 pass, 4 skip, 0 fail
Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, Impeccable
Assistant-verification: PostgreSQL contract passed: bun test apps/server/src/storage/postgres against disposable postgres:17-alpine on 55432, 85 pass, container stopped
Assistant-verification: mutation check passed: the previous attachments.ts fails all five new link and storage contract tests
Co-authored-by: Alex Lavaee <alexlavaee@microsoft.com>
The Chat and invoke_planner preflight under HARNESS=remote accepted any
in-memory attachment with a link, without asking whether its owner's browser
sign-in was still live or storage still assigned that owner. If the sign-in
expired before the link's periodic recheck, Chat saved and announced the
member's message before owner resolution failed, and invoke_planner returned
planner-owner-unavailable instead of planner-not-attached.

Both now confirm the attachment as the periodic recheck does. A stale one is
closed with the code an attached link would get (sign-in-required or
access-revoked), its ownership is released, and the request is refused with
planner-not-attached and the attach guidance, with nothing saved or queued.

A fresh attach registered its sign-in with the attempt only after the
repository and admission checks, so a sign-out during them went unnoticed and
the ownership claim then threw once storage had deleted the session, refusing
the client with unavailable (1011). The sign-in is now registered before those
checks, and a claim that storage refuses because the sign-in ended is refused
with sign-in-required (4401).

Assistant-model: Claude Opus 5.5 (fast)
Assistant-workflow: hosted-planner-slices (run b51d4e3c) repair (worker subagent)
Assistant-verification: bun run types passed: every workspace package and e2e, 14 exited with code 0
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test passed: 4672 pass, 4 skip, 0 fail
Assistant-verification: bun run ci passed: dprint, oxlint 0 errors, tokens, design contract, Impeccable
Assistant-verification: PostgreSQL contract passed: bun test apps/server/src/storage/postgres against disposable postgres:17-alpine on 55432, 85 pass, container stopped
Assistant-verification: mutation check passed: the previous attachments.ts and main.ts preflight fail all four new tests; removing only the claim fallback fails the storage-expiry attach test
Co-authored-by: Alex Lavaee <alexlavaee@microsoft.com>
@lavaman131

Copy link
Copy Markdown
Collaborator Author

End-to-end on a deployed instance

This stack was built with the repository Dockerfile and deployed with HARNESS=remote behind a TLS proxy on the same origin as /ws and /mcp. The client was an external Planner client written only from docs/planner-link.md, running in a real Atomic session on another machine against a throwaway test repository.

  • Attach and ownership: the client attached and became the document's Planner owner. A second client got planner-attached naming the holder, and the attached Planner wasn't disturbed.
  • Turns and host tools: Chopin's turns ran in the attached client: Chat replies, run cards, and plan revisions written through update_document.
  • Decisions: questions arrived over the link as input-request, were answered in the browser, and the client's runs resumed after each answer.
  • Requests with a Planner attached: a Planner request from the browser became a turn in the attached client.
  • Detach and grace period: after the client exited and the grace period passed, MCP invoke_planner returned planner-not-attached. In the browser the same request showed the refusal text and posted nothing.

The run also found that the Docker image didn't ship the new packages/planner-link workspace, so the image didn't build. That's fixed on this branch.

@lavaman131
lavaman131 merged commit dbc22ed into main Oct 11, 2026
3 checks passed
@lavaman131
lavaman131 deleted the remote-planner-link branch October 11, 2026 05:11
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