Skip to content

q2 mcp: embedded MCP launcher + sync reliability fixes (bd-81cfshmw)#277

Merged
cscheid merged 36 commits into
mainfrom
feature/bd-81cfshmw-q2-mcp-launcher
Jun 12, 2026
Merged

q2 mcp: embedded MCP launcher + sync reliability fixes (bd-81cfshmw)#277
cscheid merged 36 commits into
mainfrom
feature/bd-81cfshmw-q2-mcp-launcher

Conversation

@cscheid

@cscheid cscheid commented Jun 12, 2026

Copy link
Copy Markdown
Member

What this is

q2 mcp — a new subcommand that gives anyone with the q2 binary an MCP server for quarto-hub.com: agents (Claude Code, Claude Desktop, Cursor…) can authenticate via the loopback+PKCE Google flow and read/write Hub projects as live participants in the multiplayer automerge session.

Architecturally a thin Rust launcher: the canonical TypeScript MCP server (ts-packages/quarto-hub-mcp) is bundled by esbuild, embedded in the binary via include_dir!, extracted to a per-user cache, and executed with ambient Node — auth and sync logic stays single-sourced in TS. Design and decision history: claude-notes/plans/2026-06-11-q2-mcp-hub-auth.md.

Major pieces

  • crates/quarto-mcp-launcher + q2 mcp wiring: content-hash cache extraction with lifetime advisory locks (flock survives the exec into node), opportunistic age-gated GC, layered Node discovery (QUARTO_NODE → PATH → version-manager trees, floor Node 24), --launcher-info staleness diagnostics, placeholder embed for fresh clones.
  • Bundling: npm run bundle (esbuild; automerge steered to its base64-wasm entry; keyring native addon shipped as a mini node_modules), cargo xtask build-hub-mcp-bundle, wired into build-all/verify.
  • Auth: QUARTO_HUB_MCP_ISSUER override (loopback-http gated behind the existing insecure escape hatch, both client- and hub-side) enabling a full-stack auth e2e: mock IdP minting real RS256 JWTs → real hub binary → real loopback flow → real OS keyring → both delivery channels.
  • Reliability fixes that fell out (several found by the new e2e/tests, two by a production incident on 2026-06-12 — post-mortem in claude-notes/plans/2026-06-12-sync-client-offline-race.md):
    • sync-client logged to stdout, corrupting MCP stdio (bd-sl4o01y0)
    • server didn't exit on stdin EOF (bd-9jq2a060)
    • silent no-op when invoked through symlinked paths — would have broken npx (bd-2d8ur7e9)
    • silent offline project "creation" with memory storage (bd-xnmd5ni1 → requireOnline/PeerUnavailableError)
    • websocket errors crashed the process (bd-xzspx4r9)
    • one dangling index entry bricked whole projects for every client (bd-vm5e5u10 → graceful degradation; delete_file as self-service repair)
    • server exit raced outbound sync — created docs could die with the process (bd-10deu8h4 → bounded remote-heads drain at shutdown, loud on failure)
  • Default --server is now wss://quarto-hub.com/ws; repo .mcp.json pinned to an explicit local URL.

Verification

  • cargo nextest run --workspace: 9997 passed. Full cargo xtask verify (now 14 steps after merging main's bd-6rczoll3 ts-packages step): all green.
  • TS suites: sync-client 105, hub-mcp 185 (+2 keyring-gated), hub-client build + test:ci 97.
  • Live against production: authenticate → connect → read/write on the quarto-hub.com playground, corroborated by the hub's audit log; keyring credential reuse without re-prompt verified across both channels.
  • origin/main is fully merged in (6b8ba65f); merge is conflict-free.

Review guidance

  • Best read by area rather than commit-by-commit (~45 commits incl. two reviewed sub-strand merges): crates/quarto-mcp-launcher/ (locks/GC/discovery), ts-packages/quarto-hub-mcp/scripts/bundle.mjs, the sync-client diffs (requireOnline, dangling-entry tolerance, exit drain), and crates/quarto-hub/src/auth.rs (gated loopback-http issuer — security-relevant, please scrutinize).
  • Windows: the launcher's spawn/exit-code path compiles but has never executed on Windows — review wanted from the Windows dev before this ships to that platform (macOS-first was agreed for this phase).
  • Plans with full context: 2026-06-11-q2-mcp-hub-auth.md, 2026-06-12-sync-client-offline-race.md, 2026-06-12-graceful-dangling-entries.md, 2026-06-12-mcp-exit-sync-drain.md.

🤖 Generated with Claude Code

cscheid and others added 30 commits June 11, 2026 16:46
…d-sl4o01y0)

A stdio MCP server's stdout carries exclusively JSON-RPC frames, but
quarto-sync-client wrote 12 console.log progress lines ('Waiting for
peer connection...', '[createNewProject] ...') to stdout, corrupting
the protocol stream for real MCP hosts. Found empirically during the
q2-mcp bundle spike (bd-81cfshmw Phase 0).

- quarto-sync-client: new injectable logger seam (src/log.ts,
  setSyncLogger/syncLog); all console.log sites now route through it.
  Default sink remains console.log, so hub-client (browser) behavior
  is unchanged. Includes a source-level invariant test that no
  library file calls console.log directly.
- hub-mcp: protectProtocolStdout() after parseArgs — sync-client
  diagnostics go to stderr, and console.log itself is rebound to
  stderr as defense in depth against dependency logging. --help still
  prints to stdout (pre-protocol).
- test infra: McpTestClient now records non-JSON stdout lines
  (stdoutPollution) and gains endStdinAndWaitForExit(); new
  test-hub.ts hosts an in-process automerge-repo sync peer with a
  /health endpoint so hygiene tests exercise live-sync code paths
  deterministically (an unreachable URL fails fast at the probe and
  exercises nothing).
- stdio-hygiene.test.ts also carries the still-failing stdin-EOF
  lingering test for bd-9jq2a060, fixed next.

Note: hub-mcp's esbuild bundling scaffold (scripts/bundle.mjs, npm run
bundle, devDeps esbuild/ws/automerge-repo) rides along in package.json
in this commit; the bundle script itself lands with the q2-mcp work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MCP hosts (Claude Desktop, Claude Code, Cursor) terminate stdio
servers by closing their stdin. The SDK's StdioServerTransport never
watches for EOF — its onclose fires only on programmatic close — and
once a sync client exists, live websockets and reconnect timers keep
the event loop alive, so every agent session leaked a node process
(confirmed: still alive 4s after EOF during the bd-81cfshmw spike).

Watch process.stdin 'end' and run the shutdown path (disconnect sync,
close server, exit 0); also wire server.onclose for programmatic
transport closes, with a re-entrancy guard since server.close() fires
onclose back into shutdown.

Side effect: the hub-mcp vitest suite drops from ~11.6s to ~3.6s —
spawned servers now exit on the test client's stdin.end() instead of
riding out a 3s kill timeout per test.

Regression test: stdio-hygiene.test.ts 'exits on stdin EOF while sync
connections are live' (red before this commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The am-I-the-entry-module guard compared import.meta.url against
pathToFileURL(process.argv[1]) verbatim. Node's ESM loader resolves
import.meta.url through realpath, so any symlink in the invocation
path made the comparison fail and the process loaded modules then
exited 0 without ever starting the server. Not exotic: macOS /tmp and
/var are symlinks into /private, and npm/npx .bin shims are symlinks —
the future npx channel (bd-3tak0lyy) would not have worked at all.
Found when the bundle smoke test spawned from os.tmpdir()
(/var/folders/...).

realpathSync(argv[1]) before comparing; treat unresolvable argv[1] as
not-the-entry. Regression test drives dist/index.js through an
explicit symlink.

Also corrects the protectProtocolStdout doc comment: --help/usage goes
to stderr by package convention, not stdout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…w Phase 1)

'npm run bundle' (scripts/bundle.mjs) produces dist-bundle/: a single
index.mjs (esbuild, ESM, target node24), build-info.json (git commit,
dirty flag, build time, node target), and a mini node_modules carrying
@napi-rs/keyring plus its platform .node package(s). This is the
artifact the q2 binary will embed for 'q2 mcp', and what the future
npx channel (bd-3tak0lyy) publishes.

Key bundling decisions (full findings in
claude-notes/plans/2026-06-11-q2-mcp-hub-auth.md, Phase 0):
- @automerge/automerge is steered to its base64-wasm entrypoint via a
  resolve plugin; the 'node' export condition reads automerge.wasm
  with a __dirname-relative readFileSync that cannot survive bundling.
- @napi-rs/keyring stays external and ships as a mini node_modules
  inside the bundle dir — node resolution relative to index.mjs finds
  it; no loader rewriting.
- The 'source' export condition compiles workspace deps from their TS
  sources, so the bundle can never embed a stale workspace dist/.
- createRequire banner satisfies require() calls left by bundled CJS
  deps (ws); no shebang in the banner (esbuild hoists the entry's).

bundle.test.ts builds the bundle and drives it from os.tmpdir() —
outside the repo tree, nothing resolvable from the workspace — through
a full MCP round-trip (create_project, connect, patch_file, read_file
with multibyte content) against an in-process sync peer, plus artifact
checks, --help, stdout purity, and stdin-EOF exit. McpTestClient gains
an entry-path override to drive the bundled artifact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-81cfshmw)

Design doc for 'q2 mcp' (thin Rust launcher delegating to the bundled
TS hub MCP server), all open questions resolved with Carlos, Phase 0
spike findings recorded, Phase 1 bundling complete except xtask
wiring. CURRENT.md now points here. dist-bundle/ gitignored alongside
dist/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hmw Phase 1)

- New 'cargo xtask build-hub-mcp-bundle' (mirrors build-q2-preview-spa)
  rebuilds ts-packages/quarto-hub-mcp/dist-bundle/, the artifact
  'q2 mcp' will embed. Stale-embed precaution: a plain cargo build
  does not refresh the bundle (same trap as the 2026-05-20 preview-SPA
  incident), so the bundle build is part of build-all, ordered before
  the Rust workspace build.
- verify gains Step 11 'hub MCP package tests': builds
  quarto-automerge-schema, quarto-sync-client, quarto-hub-mcp in
  dependency order, then runs the sync-client and hub-mcp vitest
  suites (including the bundle smoke test, which exercises the esbuild
  bundler itself). These suites were previously not run by verify at
  all. --skip-hub-mcp-tests / --skip-hub-mcp-bundle opt out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bd-81cfshmw Phase 2)

New crate quarto-mcp-launcher + 'q2 mcp' subcommand: embeds
ts-packages/quarto-hub-mcp/dist-bundle/ via include_dir! (build.rs
embeds a placeholder with a cargo warning when absent, and q2 mcp then
errors at runtime pointing at cargo xtask build-hub-mcp-bundle),
extracts it to <cache>/quarto/hub-mcp/<content-hash>/, and delegates
to an ambient Node.js with all arguments passed through verbatim (the
TS server owns --help; --launcher-info prints embed/cache/node
diagnostics for stale-embed triage).

Cache safety model (plan risk 5): every instance holds a SHARED
advisory lock (fs2) on <dir>/.lock for its lifetime — the fd survives
exec on Unix via FD_CLOEXEC clearing, the launcher holds it on Windows
— and GC deletes only after winning an EXCLUSIVE try-lock plus a
14-day .last-used age gate, via trash-rename-then-delete. Kernel lock
release on process death makes crashed instances collectable with no
refcount state to corrupt. Extraction is atomic (unique .tmp sibling +
rename; losers discard), self-heals corrupt dirs, and retries when GC
races it.

Node discovery (plan risk 2): QUARTO_NODE override (too-old is an
error, not a fallthrough) → PATH → well-known locations incl.
Homebrew, volta, fnm, nvm version trees; floor Node 24; not-found
error names every rejected candidate, the override, and nodejs.org.

26 integration tests (locking, GC, races, discovery); the concurrency
test caught a .tmp-name collision (pid-only uniqueness) fixed with an
atomic counter. E2E verified against a local hub: create_project
round-trip through the real binary, stdout purity, and live
flock probes showing the lock held during the session and released
after exit — transcript evidence recorded in the plan.

CLAUDE.md gains the q2-mcp rebuild-chain section (the preview-SPA
stale-embed trap applies identically).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mw Phase 3)

Resolution order: --server flag > QUARTO_HUB_SERVER env > the
canonical quarto-hub.com sync server. The 'server is required' startup
error is gone — 'q2 mcp' (and the future npx channel) now connect to
quarto-hub.com with zero configuration, per the plan's resolved
question 3 (decided with Carlos 2026-06-11). parseArgs gains an
injectable env parameter and unit tests for all four resolution
combinations; usage text documents the default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-server done (bd-81cfshmw)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pport (bd-81cfshmw Phase 3)

The hub already supports --oidc-issuer; this is the client-side
counterpart so an MCP client can match a non-Google IdP (and so the
Phase 3 full-stack auth e2e can run a mock IdP). Default unchanged
(Google).

resolveIssuer() validates the override: https always allowed; plain
http only for loopback hosts AND QUARTO_HUB_MCP_ALLOW_INSECURE_AUTH=1
— the same escape hatch and loopback restriction as the connection
manager's insecure-transport gate. Bad config fails fast at startup,
named. oauth4webapi requires an explicit allowInsecureRequests opt-in
per call for http endpoints; threaded through discovery, the
authorization-code exchange, and the refresh grant, derived from the
discovered issuer (revocation uses plain fetch and needs nothing).

7 new unit tests cover the resolution matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cure-auth (bd-81cfshmw Phase 3)

AuthConfig::new gains an allow_insecure_issuer flag (wired to the
existing --allow-insecure-auth): plain-http issuers are accepted only
for loopback hosts and only with the flag — a plaintext remote IdP
remains rejected with a specific error. The discovery document's
jwks_uri check derives the same allowance from the already-gated
issuer (http-loopback issuer may serve http-loopback JWKS; an https
issuer can never smuggle in an http jwks_uri).

Motivation: dev/test parity with the TS client's new
QUARTO_HUB_MCP_ISSUER gate — the Phase 3 full-stack auth e2e runs a
mock OIDC IdP on 127.0.0.1 against the real hub binary, which
previously required bypassing AuthConfig::new entirely (as the
auth_bearer.rs struct-literal tests do).

5 new unit tests; all 267 quarto-hub tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bd-xnmd5ni1)

quarto-sync-client's connect/createNewProject default to a 1 ms peer
wait and silently fall back to offline mode — correct for the
IndexedDB-backed browser, a data black hole for the MCP server, whose
clients use in-memory storage: create_project returned an indexDocId
for documents that existed only in process memory and died with the
session whenever the websocket lost the 1 ms race, which the auth
path's HTTP round-trips (health probe, /auth/actor, token refresh)
made deterministic. Found by the Phase 3 full-stack auth e2e: the hub
audit log showed the ws upgrade auth_ok while the client logged
'creating project in offline mode'.

- sync-client: new requireOnline option on ConnectOptions and
  CreateProjectOptions — peer-wait failure tears down the adapter
  (no orphaned reconnect loop) and rejects with a typed
  PeerUnavailableError naming the server and budget. Defaults
  unchanged (browser offline-first behavior locked by test).
- hub-mcp: connect and createProject pass requireOnline with a 15 s
  budget that covers the auth round-trips.
- test-hub gains acceptWs:false (answers /health, destroys upgrades)
  to model the reachable-HTTP/unreachable-sync split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d-xzspx4r9)

Upstream automerge-repo's WebSocketClientAdapter.onError rethrows any
node error whose code isn't ECONNREFUSED; StoppableWebSocketClientAdapter
inherited it and NodeWebSocketClientAdapter copied the pattern. A
throw inside an event callback cannot reach any caller — it becomes an
uncaughtException, which hub-mcp's last-resort handler turns into
process.exit(1). Net effect: a mid-handshake ECONNRESET ('socket hang
up'), ENOTFOUND, or ETIMEDOUT killed the entire MCP server. Found by
bd-xnmd5ni1's requireOnline test, whose hub destroys upgrade sockets.

Both handlers now log the diagnostic (redacted on the Bearer-carrying
Node adapter) through the syncLog sink and leave recovery to the
close/retry machinery. Upstream issue to be filed against
automerge-repo — their handler has the same defect for any node
consumer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ncher (bd-81cfshmw Phase 3)

e2e-auth.test.ts drives the complete authenticated stack: a mock OIDC
IdP (test-idp.ts — discovery, JWKS, instant-consent authorize 302,
PKCE-verifying token endpoint minting real RS256 JWTs, revocation,
flow counters), the real hub binary with --oidc-client-id and an email
allowlist, the real loopback listener (the test scrapes the sign-in
URL from server stderr and plays the browser; a fake 'open' shim on
PATH keeps the real one closed), and the real OS keyring.

Asserts: sign-in-required before auth; authenticate round-trip
(1 code exchange); authenticated create/read over Bearer ws; 45s-TTL
tokens force refresh grants (observed at the IdP); a second process —
the real 'q2 mcp' launcher when its embed matches HEAD, else the dist
build — reuses the keyring credential without re-authenticating;
authenticate_clear revokes at the IdP and empties the keyring.

Gates: skips loudly without the hub binary or a usable OS keyring
(macOS-first per Carlos 2026-06-11). McpTestClient gains env/command
overrides and stderr-line capture with waitForStderr.

This e2e found and drove the fixes for bd-xnmd5ni1 (silent offline
project creation) and bd-xzspx4r9 (ws error crash) — neither was
reachable by unit tests with injected seams.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fixed (bd-81cfshmw)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d5ni1 follow-up)

The connection manager now passes auth inside the ConnectOptions bag
(with requireOnline + peerTimeoutMs) instead of the legacy positional
slot; the sync-client spy still captured the 7th positional arg, so
the Bearer-threading test read undefined. The spy now captures the
bag (falling back to positional auth) and the test additionally locks
requireOnline=true and a >1s peer budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since the --server default changed to wss://quarto-hub.com/ws
(6b615d6), the checked-in dev server entry — which passes no
--server — silently stopped meaning 'fail fast, configure me' and
started meaning 'production hub, but with no auth env vars', a
confusing dead end that surfaced in the first live q2-mcp test
session (the agent tried this server first and found no authenticate
tool). Point it explicitly at the conventional local dev hub
(ws://127.0.0.1:3000/ws) instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (bd-81cfshmw)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view (bd-10bdjmjb)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…repair capability (bd-10bdjmjb, per Carlos)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(bd-10bdjmjb)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ated, durability race identified (bd-10bdjmjb Phase 1)

Three repro shapes for 'document created during the offline window',
all GREEN in node, kept as regression guards locking the announce
behavior: fresh-create against a held-upgrade JS hub (new test-hub
harness with deferred-upgrade switch), fresh-create against a
late-starting real Rust hub, and the production-shaped restart window
(connect online, SIGKILL the hub, createFile during the gap, hub
returns on the same port and data dir, document arrives after the
retry-loop reconnect).

Verdict recorded in the plan: hello-claude.qmd was not an announce
gap — hub-client creates files through this exact (green) path. The
remaining environmental delta is browser tab lifecycle: automerge-repo
saves documents through an async-throttled write with a 100 ms
debounce (saveDebounceRate), so a doc created during an offline window
whose IndexedDB write hasn't landed at tab unload loses its bytes
entirely; the index entry survives separately, minting the dangling
entry. Part 1's fix design is amended accordingly: flush-on-create
(repo.flush) + unload-flush in hub-client, with a browser-level red
test to confirm — not re-announce.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oject; bd-vm5e5u10 promoted (bd-10bdjmjb)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…was the creator; surgery done; durability demoted to latent (bd-10bdjmjb)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing index entries

Self-contained implementation plan for a fresh agent: defect anatomy
with line numbers, required behavior (7 points incl. MCP tool surface
and the delete-as-repair story), TDD test plan (7 red tests, harness
and ghost-minting recipe), implementation sketch, session-learned
gotchas (stdout purity invariant, connect spy, fire-and-forget
syncWithFiles confirmed as unhandled-rejection source, no-tail-piping),
branch coordination (MUST branch off beads/bd-81cfshmw-q2-mcp-launcher
until it merges), and acceptance criteria.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…contract (bd-vm5e5u10)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ase 1)

Red tests for the 2026-06-12 incident shape, observed RED before any
fix (outputs recorded in the plan):
- quarto-sync-client/src/exit-drain.test.ts: create-then-disconnect
  must lose nothing; hub-unreachable disconnect must report the
  undelivered docs.
- quarto-hub-mcp/src/exit-drain.test.ts: the exact accident at stdio
  level — create_project, immediate stdin EOF, server must exit
  promptly AND the hub must hold every created doc. 64x64KB payload
  needed for a deterministic repro on loopback (4x64KB was green by
  luck; see plan).

Supporting additive surface (no behavior change):
- DisconnectOptions/DisconnectReport/UndeliveredDoc types;
  disconnect(options?) accepts and ignores drainMs for now (Phase 2
  implements the drain), returns a report.
- MemoryStorageAdapter exported from sync-client index.
- Both JS test hubs get memory storage so they announce a storageId in
  handshake metadata like the real samod hub (the chosen delivery
  signal keys off it); hub-mcp's copy gains repo + hubHasDoc (server-
  side ground truth); stop() tolerates flush-on-shutdown throwing on
  half-delivered docs.

Delivery-signal investigation verdict (recorded in plan): per-document
remote-heads sync info (DocHandle.getSyncInfo keyed by the hub's
handshake storageId), populated by automerge-repo's unconditional
sync-state path — no remote-heads gossiping flag needed; samod always
announces a storageId (source-verified; real-hub e2e in Phase 3).

Plan: claude-notes/plans/2026-06-12-mcp-exit-sync-drain.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
quarto-sync-client:
- loadFileDocuments / syncWithFiles tolerate unavailable file docs:
  recorded in a new unavailableFiles map, surfaced via the optional
  onFileUnavailable(path, docId) callback and getUnavailableFiles();
  non-unavailable errors still throw
- the index-change handler's fire-and-forget syncWithFiles call is now
  explicitly handled (was an unhandled rejection that took down
  already-open sessions in the 2026-06-12 incident)
- connect() returns entries annotated with status: 'unavailable'
  (AnnotatedFileEntry; client-side presentation only, index unchanged)
- index document staying unavailable remains fatal, with a message
  that says "project index document" — file-vs-index confusion misled
  the incident response; wording locked in exported
  fileUnavailableMessage / indexUnavailableMessage
- deleteFile / renameFile work on dangling entries (index-only edits)

quarto-hub-mcp:
- connect_project / list_files succeed and list dangling entries with
  "status": "unavailable" plus the referenced doc id
- read_file / write_file / patch_file / create_file give a per-file
  error naming the path (write/create refuse to silently repoint a
  dangling entry); project stays connected
- delete_file / rename_file work on dangling entries — delete_file is
  the self-service repair the incident needed manual surgery for
- test-hub harness gains repo + hubHasDoc (ghost-minting; mirrors the
  sync-client sibling)

TDD: 8 new tests (4 sync-client incl. locked error wording, 3 MCP
stdio-level, 1 mid-session unhandled-rejection guard) written first and
verified RED. Suites: sync-client 100, hub-mcp 182, hub-client build +
97 tests, cargo xtask verify (13 steps, 9993 Rust tests) all green.
Manual e2e against a real hub + real q2 mcp binary transcribed in
claude-notes/plans/2026-06-12-graceful-dangling-entries.md.

Out of scope (parent plan bd-10bdjmjb): retrying unavailable files on
peer arrival (D2), doctor tooling (Phase 1.5), findDoc retry policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cscheid and others added 6 commits June 12, 2026 14:47
…sed, pushed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…it (bd-10deu8h4 Phase 2)

Root cause of the 2026-06-12 data-loss incident: stdin-EOF shutdown ran
disconnectAll() -> process.exit(0) before created documents synced to
the hub; with memory storage their only copy died with the process.

sync-client:
- disconnect({drainMs}) now drains outbound document sync before
  teardown: bounded by the budget, event-driven (per-handle
  'remote-heads' + networkSubsystem 'peer' for mid-drain reconnects),
  early-return on confirmation. Default drainMs remains 0 — browser
  teardown (IndexedDB-backed, loses nothing) is unchanged.
- Delivery proof per doc: a storage-backed peer's last-confirmed heads
  (DocHandle.getSyncInfo, fed by automerge-repo's unconditional
  sync-state path) equal the handle's current heads. trackPeers now
  records each peer's handshake storageId (kept after disconnect —
  confirmed heads stay confirmed).
- Returns DisconnectReport { drained, undelivered } naming what could
  not be confirmed.

hub-mcp:
- disconnectAll({drainMs}) forwards the budget (projects drain in
  parallel) and prints a loud stderr WARNING naming the project and
  possibly-lost paths when a drain fails (stdout stays protocol-pure).
- shutdown passes SHUTDOWN_DRAIN_MS=3000: inside the 5s stdin-EOF
  promptness contract (bd-9jq2a060); binds only when the hub is gone.

Tests (red in 2246e86, green here):
- sync-client exit-drain: create-then-disconnect loses nothing;
  dead-hub disconnect reports the undelivered docs; NEW real-samod
  gated variant proves the delivery signal against the production hub
  (drained + docs present in samod on-disk storage).
- hub-mcp exit-drain (stdio): the exact accident (create_project ->
  stdin EOF) exits promptly AND the hub holds all 65 docs; dead-hub
  shutdown warns on stderr naming project + path, still exits < 5s.

Suites: sync-client 99/99, hub-mcp 181 passed/3 keyring-skipped (incl.
bundle), hub-client build + test:ci 97/97, stdio-hygiene unchanged.

Plan: claude-notes/plans/2026-06-12-mcp-exit-sync-drain.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Manual e2e through the real q2 binary against a local Rust hub,
recorded in the plan: happy path (create_project -> stdin EOF -> exit
in 8 ms, both docs verified in samod on-disk storage) and loud-failure
path (hub SIGKILLed mid-session, create during outage -> bounded
3011 ms exit, stderr names project + paths). cargo xtask verify
--skip-hub-build --skip-hub-tests: all steps passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re MCP exit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	ts-packages/quarto-hub-mcp/src/test-hub.ts
…c0e5e13

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflict resolution (xtask orchestration grew steps on both sides):
- build-all order: npm install → ts-packages dists (theirs) →
  hub-client → trace-viewer → q2-preview-spa → hub MCP bundle (ours,
  stays just before the Rust build that embeds it) → cargo build.
- verify: TOTAL_STEPS 14; their Step 6 (ts-packages build +
  quarto-hub-mcp dist smoke check) and our Step 12 (hub MCP package
  tests) both kept. Step 12 stays self-contained (its incremental tsc
  builds are no-ops when Step 6 ran) so --skip-ts-packages-build
  cannot break the test step.
- hub-mcp package.json: their cross-platform chmod build script + our
  bundle/clean scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cscheid
cscheid merged commit 6bd9d59 into main Jun 12, 2026
5 checks passed
@cscheid
cscheid deleted the feature/bd-81cfshmw-q2-mcp-launcher branch June 12, 2026 21:09
gordonwoodhull added a commit that referenced this pull request Jun 14, 2026
CURRENT.md is a per-developer symlink to the active plan file and is
listed in .gitignore (line 22), but it was tracked in git, producing
spurious "M claude-notes/plans/CURRENT.md" diffs whenever anyone
repointed the active plan.

It was deliberately untracked in e857206 ("chore: untrack
claude-notes/plans/CURRENT.md (honor .gitignore)") and then
accidentally re-added in 6bd9d59 (PR #277). Untrack it again with
git rm --cached, leaving the working-tree symlink in place.
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