Skip to content

fix(pair-mcp, story-mcp, pair): JSON pred coercion, refused-event outcome, abandoned-run read-failures, bounded unknown-tool echo - #11827

Merged
mike-thompson-day8 merged 6 commits into
mainfrom
worker/pair-mcp-cluster-fix
Oct 30, 2026
Merged

mike-thompson-day8 merged 6 commits into
mainfrom
worker/pair-mcp-cluster-fix

Conversation

@mike-thompson-day8

Copy link
Copy Markdown
Contributor

Fixes the correctness findings on the Pair MCP server, the Story MCP server and the Pair preload's dispatch projection (rf2-lgzco.33, rf2-lgzco.35, rf2-lgzco.47), plus one test-docstring sentence (rf2-i79mi).

What changed

rf2-lgzco.33 (pair-mcp): JSON pred / signals coercion (P2)

  • record and watch-until now coerce a JSON-object pred or JSON signals the way watch-epochs coerces its pred. An :app-db signal or a pred :path parses the way get-path parses a path (args/parse-path-arg). A colon-led :equals or :sub element mints a keyword under the id grammar (args/->id-keyword). Any other string stays a string, which is option (a) in the finding. The EDN-string arms are unchanged.
  • watch-until now uses the shared record/parse-pred-arg. record's stop object coerces its nested :pred the same way.
  • record_test's held-predicate test used to let the stub decide :held?. It now reads the poll form and checks that the keyword path and comparand reach it.
  • The review's P3 lead (strip-fns and RegExp) is real. I reproduced it in the fixture's CLJS build: the EDN read-back of registrar-describe threw on a [:re #"…"] schema. strip-fns now turns a RegExp into its source string, and [:re "…"] is the same Malli schema. machine_describe_test.clj evaluates the shipped walk under Babashka, which has no regexp?, so that test now defines one from the JVM predicate.

rf2-lgzco.35 (story-mcp)

  • P3: after a run is abandoned at its deadline, read-failures used to read the torn-down frame and answer {:status :pass :total 0}. Each variant now keeps its deadline outcome's records until its next run, and read-failures reads them instead: {:status :error :total 1} with the :rf.error/run-failed record. This is option (a), and it matches the run-variant reply. tools_test.clj's fence test used to pin the :pass; it now expects that error.
  • P4, and the ruled pair-mcp mirror: both servers now echo an unknown tool name longer than 128 characters cut to 128 characters plus .... A name more than twice the longest listed name skips the near-name search, since no listed name could fall within its threshold. Each server has its own fix, and tools/mcp-base is untouched.

rf2-lgzco.47 (Pair preload)

  • P2, ruled (a): pure.cljc gains cascade-refusal-ops beside the throw set, and the throw set still mirrors Xray's. Three refusals now count: :rf.error/effect-map-shape, a schema failure at :where :event, and a schema failure carrying :rollback? true. Each folds into :errors, which forces :outcome :error and keeps the event out of :no-op?. So dispatch and dispatch-dry-run now return what recipes.md:273 already promises. A schema failure at :sub-return / :fx-args leaves the event committed and is not counted.
  • P3: epoch-matches? now matches nothing on a present but unparseable :timing-ms. The watch-epochs tool also refuses such a value up front with :rf.mcp/invalid-arg naming :pred.

rf2-i79mi: the error_boundary_test namespace docstring now says a failed or interrupted nREPL round-trip also reaches relay 1, as invoke-and-guard's docstring does. This is prose only.

Spec, one sentence per change:

  • pair-mcp 003-Tool-Catalogue.md: the unknown-tool echo cut, and the :timing-ms refusal.
  • story-mcp 001-Wire-Protocol.md: the unknown-tool echo cut.
  • story-mcp 002-Tool-Registry.md §read-failures: what it reads after a deadline.

No new tool args, no MCP-base surface, and no descriptor text changed, so tool-descriptors.edn is untouched.

Quality gates

Each exit code below was captured on the same command line as its redirect.

Gate Baseline (trunk) With the change
npm --prefix tools/re-frame2-pair-mcp test exit 0, 421 tests / 1355 assertions exit 0, 424 / 1371
clojure -M:test in tools/story-mcp exit 0, 153 / 362 exit 0, 155 / 370
Pair fixture pure-test (shadow-cljs compile pure-test && node target/pure-test.js, run through the lane helper) exit 0, 56 / 178 exit 0, 59 / 189
skills-structural bb tests (tests/prompts + tests/runtime/*) — all 15 files exit 0
scripts/check_doc_slugs.py — exit 0 (the spec edits add no links or anchors)
  • Pair fixture baseline: taken with the pure.cljc / runtime.cljs edits in place but the trunk test files, so the counts are the trunk's.
  • skills-structural: the first run after the strip-fns change caught machine_describe_test.clj at exit 1, which is how the regexp? gap above was found.

Each delta matched the prediction from the diff:

  • pair-mcp: +3 tests, +16 assertions.
  • story-mcp: +2 tests, +8 assertions.
  • fixture: +3 tests, +11 assertions.

Final-base gate: this PR's CI. The local runs were taken before the rebase onto 8fca3b354d, and none of the upstream commits since touch these paths. The live and hermetic MCP job (mcp_live) is left to CI.

Controls (each planted, watched fail, restored)

Each restore was verified by git hash-object against the committed blob, and git diff --quiet exited 0.

Plant Run exit What went red Controls that stayed green
pair-mcp: JSON coercion, unknown-tool echo and near-name guard, and the timing refusal reverted to trunk behaviour 1 10 failures, all in the four new or changed tests the watch-epochs JSON-pred test; unknown-tool-refused-before-connection
story-mcp: deadline records ignored; echo uncut 1 4 failures the near-name unknown-tool test; the fence assertions
fixture: refusal fold off, timing check reverted, RegExp passed through 1 6 failures and 1 error the genuine no-op; the :sub-return / :fx-args non-refusals; the parseable ">=0"

Detail on the reds:

  • pair-mcp: with trunk behaviour restored, the unknown-tool refusal for a 32,000-character name was 64,626 characters long. That reproduces the pair-mcp half of the echo finding, which the review had only inspected.
  • story-mcp: read-failures read [:pass 0 []], and the refusal was 64,488 characters.

… the unknown-tool echo (rf2-lgzco.35)

A run abandoned at its lifecycle deadline has its frame torn down, so
read-failures read the empty frame and answered {:status :pass :total 0},
the never-ran green. The deadline outcome's records are now kept per
variant until its next run, and read-failures reads them in place of the
frame: {:status :error :total 1} with the :rf.error/run-failed record,
agreeing with the run-variant reply.

The unknown-tool result echoed the caller's name whole, twice, outside the
response cap, after a near-name search linear in its length. A name over
128 characters is now echoed cut, and one more than twice the longest
listed name skips the search, which could not have matched.
… watch-epochs does (rf2-lgzco.33)

JSON carries no keywords, and js->clj :keywordize-keys keywordises keys
only, so a JSON pred {"signal":0,"equals":":done"} compiled to
(= (get sample 0) (quote ":done")) and never held, and a JSON signal
{"app-db":[":upload",":status"]} sampled nil. The JSON arms now coerce
the way watch-epochs' pred does: an :app-db signal or a pred :path parses
as get-path's path, and a colon-led :equals or :sub element mints a
keyword under the id grammar. The EDN-string arms are unchanged.

record_test's held-predicate test now reads the poll form for the
keyword path and comparand, rather than letting the stub decide :held?.
…ch-epochs :timing-ms (rf2-lgzco.35, rf2-lgzco.47)

The unknown-tool refusal echoed the caller's name whole in :tool and
:hint, after a near-name search linear in its length. A name over 128
characters is now echoed cut, and one more than twice the longest
registered name skips the search, which could not have matched.

watch-epochs passed a pred :timing-ms through unchecked, and the
preload's matcher read an unparseable one (">100ms") as no constraint.
The tool now refuses it up front with :rf.mcp/invalid-arg naming :pred.
…lay 1 (rf2-i79mi)

The namespace docstring said relay 1 is reached only by throws before
the nREPL round-trip; a failed or interrupted round-trip reaches it too,
as invoke-and-guard's docstring says.
…ing-ms matches nothing; a regex in handler meta reads back (rf2-lgzco.47, rf2-lgzco.33)

A :boundary? true schema rejection, an app-db schema rollback and a
refused effect map each settle the epoch :outcome :ok having committed
nothing, so dispatch reported them as {:outcome :ok :no-op? true} and
dispatch-dry-run as :outcome :ok. pure.cljc gains a refusal set beside
the throw set (which still mirrors Xray's): an :rf.error/effect-map-shape,
or a schema failure at :where :event or carrying :rollback? true, folds
into :errors, forcing :outcome :error and clearing :no-op?.

epoch-matches? read a present but unparseable :timing-ms (">100ms") as
no constraint, so it matched every epoch; it now matches none.

strip-fns passed a RegExp through, and EDN has no regex literal, so a
registration whose :schema held [:re #"..."] made handler-meta's whole
reply unserializable. A RegExp now projects to its source string.
…pped walk (rf2-lgzco.33)

machine_describe_test evaluates the preload's strip-fns under Babashka,
which has no cljs.core regexp?; the JVM predicate stands in for it.
@mike-thompson-day8
mike-thompson-day8 marked this pull request as ready for review October 30, 2026 01:29
@mike-thompson-day8
mike-thompson-day8 merged commit b0bdabd into main Oct 30, 2026
61 checks passed
@mike-thompson-day8
mike-thompson-day8 deleted the worker/pair-mcp-cluster-fix branch October 30, 2026 01:33
mike-thompson-day8 added a commit that referenced this pull request Oct 30, 2026
Merge: b0bdabd at 2026-10-30T01:33:21Z.
Completeness: The JSON signal/predicate fixes, refused-event outcomes, timing rejection, deadline reread, bounded unknown-tool echoes, regex metadata readability and docstring correction are present. Full patch and all four complete source beads read.
Correctness: Both run and preview routes clear deadline records on the next attempt; read-failures retains the timeout error after teardown. Parser coercion preserves EDN and literal string controls; timing grammar agrees at both ends. Reported fault plants and CI accepted. No material new finding.
Implementation quality: Shared parsing avoids another divergent path, refusal classification preserves the throw-set contract, and local echo bounds keep recovery hints. Focused changes use existing projection and lifecycle seams.
Findings: none.
Source forward pointers: none.
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