Skip to content

release: 3.46.0 integration train (29 PRs + memory/neural/cli-core leaf publishes) - #3443

Merged
ruvnet merged 90 commits into
mainfrom
release/3.46.0
Sep 26, 2026
Merged

ruvnet merged 90 commits into
mainfrom
release/3.46.0

Conversation

@ruvnet

@ruvnet ruvnet commented Sep 26, 2026

Copy link
Copy Markdown
Owner

Summary

Integration train for 3.46.0: 29 reviewed PRs plus the #3359 fix, validated together, with the three unbundled leaves published standalone first.

MERGE WITH A MERGE COMMIT, NOT SQUASH. The included PR heads are ancestors of this branch; a squash would leave all of them open.

Included (merged into this branch, each read in full by a security reviewer)

Standalone leaf publishes (not bundled, so they would otherwise reach nobody)

Published and registry-verified (integrity matches the local pack) before this PR:

Fixed during validation

vitest.config.ts aliased @claude-flow/security by prefix, so #3382's new @claude-flow/security/safe-git import resolved to .../src/index.ts/safe-git (ENOTDIR) and failed ~10 cli test files. #3382 was red for this reason, not a flake. Subpath alias added ahead of the bare alias.

Validation

  • Full cli suite: 4013 pass, 3 fail. The 3 (init-memory-package-resolver-2545 x2, policy-runtime x1) fail identically on clean main in the same environment.
  • Package suites: security 586, mcp 78, plugin-agent-federation 649, codex 242, swarm 237, performance 110, integration 75, memory 550, neural 139: all pass.
  • Smokes: github-safe injection (all 3 helper copies), witness precondition, metaharness no-tool-time-npx (26) and pin-alignment (12), leaf-publish audit, ci-test-ratchet (8).
  • Witness regenerated on a clean fully built tree: 117/117 verified on linux/macos/windows (3.45.0 could only carry forward 18).
  • Helpers manifest re-signed at 3.46.0; helper-signing test 6/6.

Release-note items

Known gaps

Deliberately NOT included

#3333 (would deny every Codex worker MCP call: maxConcurrency:1 with no concurrency supplied), #3268 (WebSocket fails open with no tokens), #3339 (breaking hive-mind_init), #3393, #3369 and #3387 (need signing / lockfile work), #3425 and #3428 (arbitrary-path dbPath; ancestor-directory CLI execution), #3432 #3433 #3435 #3437 #3440 #3442 #3341 #3373 (change requested), #3438, #3295 #3296 #3298 #3270 #3307 #3309 #3310.

🤖 Generated with RuFlo

PerryLink and others added 30 commits September 13, 2026 17:00
…ait (evaluated, ACCEPT)

UnifiedSwarmCoordinator.waitForTaskCompletion()/waitForQueuedTask() busy-polled
state.tasks via setInterval(...,100) despite the coordinator already extending
EventEmitter and already firing task.completed/task.failed at every terminal
task-status transition. Rewritten to be event-driven with a synchronous
pre-check for an already-terminal task and a setTimeout fallback for the
timeout case; setMaxListeners raised to avoid spurious warnings at realistic
concurrency (adversarial-critic finding, fixed same session).

4 new discriminating tests (baseline fails 3/4, candidate passes 4/4 via
stash isolation); real wall-clock benchmark 100.359ms -> 0.206ms mean
(~487x, N=20); 234/234 full swarm-package suite, tsc clean.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01VWUxwsNQVofSGXi8us9dEc
acquireLock() records {pid, acquiredAt} in .claude-flow/policy/state.lock
but only ever reclaimed a lock by mtime age (LOCK_STALE_MS = 30 s). Every
MCP tool call, reads included, passes through authorizeMcpTool() ->
withPolicyTransaction() -> acquireLock(). When a server is killed inside a
transaction (SIGKILL, OOM, or a default-action SIGTERM: the bin/cli.js
stdio loop installs no handler), its finally block never releases the
lock, so each call on a fresh server waited LOCK_WAIT_MS and failed with
policy-state-lock-timeout until the orphan aged past 30 s: five 5 s
failures after every such crash.

Reclaim the lock at once when its owner is provably gone:
- The lock now also records host, platform and, on Linux, PID namespace
  and kernel boot id. The pid is trusted only when every field matches
  the reader's, because a sandboxed CLI (bwrap --unshare-pid), a
  container, another kernel boot or another machine sees ESRCH for a live
  owner. host alone is not an identity: two machines sharing a directory
  can report the same hostname, and every Linux init PID namespace has
  the same inode (PROC_PID_INIT_INO).
- Only ESRCH counts as dead (EPERM and any other error mean alive), and
  the pid must be a number, an integer, and in 1..2^31-1 (process.kill
  coerces a numeric string).
- The lock is read only if it is a small regular file (lstat, then
  O_NOFOLLOW|O_NONBLOCK, fstat, at most 1 KiB), so a symlinked FIFO or
  device cannot block the event loop.
- After the probe the lock is re-read and must be byte for byte the one
  that was read, so a lock that changed hands during the probe is not
  deleted. Comparing dev/ino/ctime instead is unsound on Linux: ext4
  reuses a freed inode number at once, and before kernel 6.13 two files
  created in one clock tick share a ctime. Replaying the handover on a
  non-multigrain ext3 mount, the stat triple deleted the live new owner's
  lock in 192 of 200 runs; the byte comparison in 0 of 200.

Every other lock, including one written by an older version, keeps the
30 s mtime rule. ADR-324's lock paragraph is updated to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTedGmPKpEdArVBMMn3JaP
`mcp start` on the stdio transport force-"restarted" whatever server was
recorded in the shared PID file — a single slot per os.tmpdir() (per user
on macOS/Windows; the machine-wide /tmp on Linux unless TMPDIR is set).
It sent SIGKILL to that PID, because a stdio server "can't be health-
checked". The victim was simply the last server started through the
command parser: another terminal's stdio server, or a live http/websocket
server that other clients were using. For the same reason, a terminal
stdio server made a later `mcp start -t http` refuse with "MCP Server
already running".

A stdio server is owned by the client that spawned it, over its own
pipes, and any number can run side by side. Only a port-bound transport
is single-instance. ADR-071 made this singleton safe against
self-detection and PID reuse; a stdio server has no business claiming or
killing the shared slot at all. So:

- commands/mcp.ts: a stdio start kills a recorded server only on an
  explicit --force; http/websocket keep their guard unchanged.
- MCPServerManager: a stdio server no longer refuses to start over the
  PID file, writes it, removes it on stop(), or health-checks it. While
  it serves stdio, getStatus() and checkHealth() report the process
  itself rather than whatever PID is recorded. A stale record is still
  cleaned up by the pre-start status read, as before.

The documented piped-stdin `mcp start` (how MCP clients launch it) is
answered by bin/cli.js's inline fast path, which never touches the PID
file. A piped start whose argv does not begin with `mcp` (a leading
global flag) does reach the parser, and is covered too, because the fix
keys on the transport rather than on the TTY.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTedGmPKpEdArVBMMn3JaP
…ecord durable identity (#3364)

Review round 2 (@ruvnet on #3365). The first round kept a one-way "has served
stdio" flag so a second stop() could not delete another server's record. That
flag outlived the server it described: after start -> stop, an independent
status query still reported the manager running and checkHealth() still
reported it healthy. On upstream e558f0c the same replay reported
{"running":true,...} and {"healthy":false,"error":"No PID file found"} — the
status half is the pre-existing #2934 fallback, the health half was ours.

The flag is now the manager's live lifecycle ('idle' | 'running' | 'stopped'):

  - while it is serving stdio the manager IS the server, so it reports itself
    and never reads or clears the record it deliberately never wrote (the 30s
    health monitor calls checkHealth());
  - once stopped it reports running: false and healthy: false rather than
    whatever PID the shared file happens to hold;
  - 'idle' — a manager that never started, which is what `mcp status`,
    `mcp health` and `mcp stop` build — keeps today's behaviour exactly,
    including the #2934 stdio fallback.

stop() no longer needs a sticky flag: a stopped manager reports not running,
so a second stop() returns at its own guard.

The PID record now carries durable instance identity, the same model #3363
reached for the policy lock: host, platform, Linux PID namespace and kernel
boot id, and the owner's process start time (/proc/<pid>/stat field 22 on
Linux, `ps -o lstart=` elsewhere). A recorded PID is believed only while all
of it still matches, so a PID the OS has since handed to another process, or
one from another boot, host or namespace, is no longer treated as a live
server — it can no longer make an http start refuse, or be SIGKILLed by
`mcp start --force`. A server retracts its record only while the file still
holds, byte for byte, what it wrote.

Format compatibility: line 1 is still the bare PID, so an older ruflo's
parseInt and `head -1` both read it unchanged; line 2 is the identity. A
bare-integer record — an older ruflo's, or `start-mcp.sh --daemon`'s — parses
with no identity and behaves exactly as today. v3/scripts/start-mcp.sh now
reads the first line.

Not covered: PID reuse and boot detection need the identity line, so a record
written by an older ruflo keeps today's exposure; Windows has neither /proc
nor ps, so its records carry no start time and PID reuse stays unqualified
there, as isProcessRunning()'s process-name check already is.

Tests: 13 added (20 total in the file), covering start -> stop -> independent
status/health denial, concurrent ownership, PID reuse and non-owned processes,
and the Windows-shaped inputs. All 13 fail on 85c6888 except the two
labelled as pinning existing behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTedGmPKpEdArVBMMn3JaP
…led instead of npm-installing at tool-call time (#3366)

On a stock ruflo 3.42.4 install every metaharness_* MCP tool reached for the
npm registry at tool-call time, although the packages it needs are already on
disk. Two causes, in the same six helper files, which is why they ship
together: the helper PINS rejected what ruflo installs, and three code paths
never looked for an installed copy at all. Fixing only the pins leaves darwin
and the memory tools on npx; fixing only the lookups leaves them resolving a
pin that nothing on disk satisfies.

1. Pins (metaharness, @metaharness/darwin)

The metaharness_* tools resolve MetaHarness through the plugin helpers' own
tilde pins, not through package.json. Those pins were never bumped:
_harness.mjs stayed at ~0.3.0 after the CLI declared metaharness ^0.4.1
(b8ec03f), and _darwin.mjs stayed at ~0.8.0 while the CLI moved
@metaharness/darwin to ~0.9.0 (#2958) and ~0.10.2 (#3262). On a stock install
(metaharness 0.4.2, darwin 0.10.2) findLocalPackageDir() therefore rejected
the bundled metaharness, and every score / genome / mcp_scan / threat_model /
oia_audit / learn call ran
`npm install --prefix ~/.ruflo/metaharness-cache-0.3.0 metaharness@~0.3.0`
at tool-call time — a download of the older 0.3.2 (180 s install budget inside
a 120 s MCP subprocess budget), degraded offline. The darwin tools fetched
darwin 0.8.x through npx while gepa imported the bundled 0.10.2.

- _harness.mjs: METAHARNESS_PIN_VERSION ~0.3.0 -> ~0.4.1 (= CLI ^0.4.1)
- _darwin.mjs:  DARWIN_PIN_VERSION      ~0.8.0 -> ~0.10.2 (= CLI ~0.10.2)
- scripts/check-metaharness-pins.mjs: the watcher compared only the CLI's
  declared ranges (and MH_DARWIN_PIN), so it reported "all current" while the
  pins that decide what runs were two minors behind. It now reads each
  helper's *_PIN_VERSION: the pin must be a `~X.Y.Z` range (the only form
  _invoke.satisfiesTildeRange accepts), and must cover the declared range, or
  admit npm latest when the CLI declares nothing (redblue). An unreadable
  constant is fatal. `satisfies` now shares a `bounds()` helper with the new
  `covers()`; the results for the existing rows are unchanged.
- metaharness-pin-drift.yml also triggers on the three helper files.
- The two current-pin comments in TypeScript (distill-oracle.ts MH_DARWIN_PIN,
  metaharness-tools.ts ADR-153 header) named the old ~0.8.0 plugin pin; they
  now name ~0.10.2. Comments only, no behaviour.

2. No npm/npx at tool-call time (darwin, redblue, memory)

- _darwin.mjs ran `npx -y -p @metaharness/darwin@<pin> metaharness-darwin` on
  every evolve / bench / security_bench call: npm resolved the range each
  time, it ignored the optionalDependency the CLI ships, and it could run a
  different darwin than the gepa import in the same plugin. It now resolves
  like _harness.mjs — installed copy satisfying the pin, then the one-time
  versioned darwin-cache-<pin> that gepa already shares, then
  `process.execPath <abs bin>` with shell:false.
- _redblue.mjs always npm-installed into redblue-cache-<pin> on first use,
  never looking at the installed copy (ruflo 3.42.4 carries 0.1.4). It now
  checks the installed copy first. The chosen CLI path is realpath'd, because
  redblue only dispatches when import.meta.url === file://argv[1]; a
  pnpm-symlinked package dir (or a cache base under /tmp on macOS) otherwise
  makes it exit 0 having done nothing. Its spawn moves to process.execPath
  with shell:false: the argv carries MCP-supplied --config/--out/--in, which
  under the previous `shell: win32` went through cmd.exe unquoted.
- audit-list / audit-trend / oia-audit / similarity ran
  `npx @claude-flow/cli@latest memory ...` — an npm-registry resolution per
  call (the check _harness.mjs already removed for its own npx path), and
  possibly a different CLI version than the server that spawned the tool
  (#3306; cold-cache npx cost #3145/#3154). Without registry access oia_audit
  reported persisted:false and audit_list silently showed 0 records. They now
  call _invoke.runRufloCli(): process.execPath + bin/cli.js of the
  @claude-flow/cli that ships the plugin (<cli>/plugins/ruflo-metaharness, or
  v3/@claude-flow/cli in a checkout). A candidate needs dist/src/index.js next
  to bin/cli.js (mcp-launch.cjs's unbuilt-checkout guard) plus a package.json
  named @claude-flow/cli (a new check, so an unrelated parent directory can
  never qualify). CLI_CORE=1 still opts into `npx @claude-flow/cli-core@alpha`,
  and a layout with no usable local CLI still falls back to
  `npx @claude-flow/cli@latest`.
- drift-from-history.mjs: drop its unused CLI_PKG constant.

The two new lookups end in a spawn of a package bin with MCP-supplied argv, so
they pass findLocalPackageDir(..., { fromCwd: false }) and search only the
ruflo install that owns the plugin — never the tool's cwd or its ancestors.
The pre-existing metaharness lookup keeps its cwd walk unchanged.

Tests (both hermetic, published layout, stub packages, npm/npx PATH trap):

- test-pin-alignment.mjs (+ smoke 17z74a): 3 passed / 9 failed on main,
  12 / 0 here.
- test-no-tool-time-npx.mjs (+ smoke 17r2): 7 passed / 19 failed on main,
  26 / 0 here. It covers both darwin success branches (installed copy and the
  versioned cache), the memory round trip through the shipping CLI, the kept
  contracts (CLI_CORE, npx fallback, a bin/cli.js that is not
  @claude-flow/cli, darwin absent -> degraded exit 0), and the hardening
  above: a darwin/redblue in the tool's own cwd node_modules is never
  executed, a cache dir with package.json but no bin gets one reinstall
  instead of degrading forever, and neither helper spawns the resolved bin
  through a shell.

ADR-150 is unchanged: no static @metaharness import is added, no new
dependency, and every unresolvable path still returns the same
{degraded:true, reason} with exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTedGmPKpEdArVBMMn3JaP
…ess (#3376)

`embeddings init` skipped the ONNX download whenever the optional
`@claude-flow/embeddings` package was absent — which is every plain
`npm i -g ruflo`, since that package is not declared in the CLI's
package.json at all. It then slept 500ms faking progress, printed a dimmed
"(Skipped — …)" note on the spinner's tail, and fell through to the success
path: "Embedding subsystem initialized", a settings table asserting the model
and 384 dimensions, a `.claude-flow/embeddings.json` recording a `modelPath`
that contains no model, and exit 0.

This is the command `memory/embedding-policy.ts` points users at in its
`[no-stub]` error, so the documented repair for broken embeddings reported
success and changed nothing.

- A download that was requested and did not happen now ends in
  `spinner.fail` + `output.printWarning` + `{ success: false, exitCode: 1 }`,
  per the CLI command contract in docs/MCP_CLIENT_GUIDE.md. `--no-download`
  still exits 0: no download was asked for.
- `embeddings.json` gains additive optional `modelDownloaded` and
  `modelSkipReason` keys. Every pre-existing key keeps its name and meaning,
  so older readers are unaffected.
- Both `setTimeout(r, 500)` fake-progress delays are gone. The second, in
  `embeddings models --download`, also reported `success: true` for a skip;
  it now fails the same way.

Scope is items 1-3 of the issue. The missing `status`/`download` subcommands
(item 4) are deliberately left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTedGmPKpEdArVBMMn3JaP
…3360)

bridgeGetEntry() consults a per-process TieredCache before the database,
and each process only invalidated that cache on its own writes. Several
MCP servers routinely share one store (Claude Code, Codex and Grok each
run `mcp start` against the same .swarm/), so once server B had read a
key, an update or delete made by server A never reached B: B kept
returning the old value (found: true) until its cache entry expired
(5-minute TTL by default, not extended by reads).

Before a cached read, check SQLite's PRAGMA data_version (prepared once
per handle), which changes only when a different connection has
committed to the file, and clear the cache when it moved. Own writes
don't trip it, so hits stay cheap (~1 us added).

Native better-sqlite3 handles only: agentdb's sql.js fallback works on a
private image that never sees other processes' commits, so there is
nothing to detect and it is skipped (isWasm / no inTransaction) rather
than probed. A handle that can't prepare the check is not probed again. A check that
cannot validate the store on one read clears the cache for that read,
leaves the baseline alone and is retried — both when it throws (e.g.
SQLITE_BUSY) and when it answers without a usable number, since neither
is evidence the store is unchanged. The check is fail-open toward the
caller: it can never break the read it guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTedGmPKpEdArVBMMn3JaP
…chet failure (#3208)

The ratchet runs vitest with `--reporter=json --outputFile=…`, so nothing
vitest prints reaches the job log and the only record of an unexpected
failure is the file name. Recovering the assertion means downloading the
`test-results-ubuntu-latest` artifact — and a rerun replaces it, so after a
rerun attempt 1's assertion is gone for good.

The failure messages are already in the report this process parsed. Printing
them costs nothing, needs no new artifact, and survives a rerun, because job
logs are per-attempt.

`formatUnexpectedFailures()` is exported and pure so it unit-tests alongside
`evaluateTestReport()`. Every field it reads is optional by design: a
file-level abort ("No test suite found in file") carries `message` and no
assertions, and a report from another reporter version may carry neither. In
both cases the filename still prints, exactly as before, and the PASS path is
untouched.

Verified against a real failing run rather than a fixture: replaying run
35521170139's `vitest.json` through this script turns

      + v3/@claude-flow/cli/__tests__/memory-search-scores-3327.test.ts

into that same line plus

      ✗ memory_search score contract (#3327 Finding B) preserves standard fallback on import failure
        AssertionError: expected [ { key: 'a', …(5) } ] to deeply equal [ { key: 'a', …(4) } ]

which is the line that would have identified the failure in #3208 without a
second run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTedGmPKpEdArVBMMn3JaP
Bumps [agentic-flow](https://github.com/ruvnet/agentic-flow) from 2.1.2 to 2.1.3.
- [Release notes](https://github.com/ruvnet/agentic-flow/releases)
- [Changelog](https://github.com/ruvnet/agentic-flow/blob/main/docs/CHANGELOG.md)
- [Commits](https://github.com/ruvnet/agentic-flow/commits/v2.1.3)

---
updated-dependencies:
- dependency-name: agentic-flow
  dependency-version: 2.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
# Conflicts:
#	docs/dream-cycle/LEDGER.md
…age vitest alias

Vite alias keys prefix-match, so the bare '@claude-flow/security' alias rewrote
the safe-git subpath import to '.../src/index.ts/safe-git' (ENOTDIR) and failed
about ten cli test files once #3382 added the import.

Co-Authored-By: RuFlo <ruv@ruv.net>
…0, cli-core alpha.6

These three resolve from the registry, not the train tarballs, so the source
changes already on main (#3352+#3359 memory upsert, #3330 MoE load-balance,
#3385 validateEnv denylist) reach nobody without a standalone publish.

Co-Authored-By: RuFlo <ruv@ruv.net>
Pin the three standalone-published leaves exactly (memory alpha.26 in both
dependencies and optionalDependencies, neural alpha.10, cli-core alpha.6) and
hand-edit the lockfiles minimally: pnpm importer specifiers plus one new
memory registry snapshot, and the root package-lock cli-core/neural entries.

Co-Authored-By: RuFlo <ruv@ruv.net>
Co-Authored-By: RuFlo <ruv@ruv.net>
…re-sign manifests

Regenerated on a clean, fully built tree, so all 117 entries verify on each OS
(no dist entries carried forward unattested). Regen was run per OS with the
manifest os field set explicitly, since regen.mjs stamps the runner's OS.

Co-Authored-By: RuFlo <ruv@ruv.net>
@ruvnet
ruvnet merged commit 39ea61f into main Sep 26, 2026
131 checks passed
@ruvnet
ruvnet deleted the release/3.46.0 branch September 26, 2026 23:36
ruvnet added a commit that referenced this pull request Sep 27, 2026
…-run-gap notes

Records the 2026-09-26 batch merge clearing #3330/#3350/#3352/#3357/#3385/#3395
(GitHub-verified via pull_request_read), #3378's landing via batch PR #3443 +
npm publish #3452, and a new confirmed no-run gap on 2026-09-23/25/26. Appends
tonight's row (#3475/#3476, ACCEPT-scoped).

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_017NSCBLZggg81jUaa8RvZV7
ruvnet added a commit that referenced this pull request Oct 9, 2026
…-run-gap notes

Records the 2026-09-26 batch merge clearing #3330/#3350/#3352/#3357/#3385/#3395
(GitHub-verified via pull_request_read), #3378's landing via batch PR #3443 +
npm publish #3452, and a new confirmed no-run gap on 2026-09-23/25/26. Appends
tonight's row (#3475/#3476, ACCEPT-scoped).

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_017NSCBLZggg81jUaa8RvZV7
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.

6 participants