diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 92ffece7..be9bd50d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -88,7 +88,12 @@ jobs: # same teardown abort on store-touching commands (`memory search` → correct # output, rc 134), so an `--only memory-routes` step added here would need the same # guard. Remove continue-on-error once that issue closes. + - name: Require Node 22.15+ for the macOS resolution hook + if: matrix.os == 'macos-latest' + run: node -e "const [major, minor] = process.versions.node.split('.').map(Number); if (major < 22 || (major === 22 && minor < 15)) { console.error('trace-ort requires Node 22.15+'); process.exit(1); }" + - name: Deep proof against the live packages (learning) + if: matrix.os != 'macos-latest' continue-on-error: true env: HOME: ${{ runner.temp }}/kit-home @@ -97,6 +102,52 @@ jobs: APPDATA: ${{ runner.temp }}/kit-home/AppData/Roaming run: node bin/agentic-kit.mjs status --refresh=live --only learning + - name: Deep proof against the live packages (learning, traced macOS) + if: matrix.os == 'macos-latest' + continue-on-error: true + shell: bash + env: + HOME: ${{ runner.temp }}/kit-home + USERPROFILE: ${{ runner.temp }}/kit-home + XDG_CONFIG_HOME: ${{ runner.temp }}/kit-home/.config + APPDATA: ${{ runner.temp }}/kit-home/AppData/Roaming + NODE_OPTIONS: --import=${{ github.workspace }}/scripts/trace-ort.mjs + TRACE_ORT_LOG: ${{ runner.temp }}/trace-ort.jsonl + run: | + set +e + node bin/agentic-kit.mjs status --refresh=live --only learning + learning_rc=$? + LEARNING_RC="$learning_rc" NODE_OPTIONS='' node --input-type=module -e ' + import fs from "node:fs"; + import crypto from "node:crypto"; + const source = fs.readFileSync("scripts/trace-ort.mjs"); + fs.writeFileSync(process.env.RUNNER_TEMP + "/trace-ort-receipt.json", JSON.stringify({ + sourceSha: process.env.GITHUB_SHA, + hookSha256: crypto.createHash("sha256").update(source).digest("hex"), + node: process.version, platform: process.platform, arch: process.arch, + learningExitCode: Number(process.env.LEARNING_RC), + tracePresent: fs.existsSync(process.env.TRACE_ORT_LOG), + }) + "\n"); + ' + exit "$learning_rc" + + - name: Check macOS trace artifact + if: always() && matrix.os == 'macos-latest' + shell: bash + run: | + test -s "$RUNNER_TEMP/trace-ort.jsonl" || { echo '::error::trace-ort artifact absent or empty'; exit 1; } + test -s "$RUNNER_TEMP/trace-ort-receipt.json" || { echo '::error::trace-ort receipt absent or empty'; exit 1; } + + - name: Upload macOS learning resolution trace + if: always() && matrix.os == 'macos-latest' + uses: actions/upload-artifact@v7 + with: + name: macos-learning-ort-trace + if-no-files-found: error + path: | + ${{ runner.temp }}/trace-ort.jsonl + ${{ runner.temp }}/trace-ort-receipt.json + clean-mac-setup: name: clean macOS setup (packed artifact) runs-on: macos-latest @@ -134,7 +185,7 @@ jobs: ollama serve > "$RUNNER_TEMP/ollama.log" 2>&1 & AK_OLLAMA_PID=$! trap 'kill "$AK_OLLAMA_PID" 2>/dev/null || true' EXIT - for attempt in {1..30}; do + for ((ak_readiness_attempt=0; ak_readiness_attempt<30; ak_readiness_attempt++)); do curl --fail --silent http://127.0.0.1:11434/api/version >/dev/null && break sleep 1 done @@ -142,6 +193,7 @@ jobs: git -C "$AK_PROJECT" init (cd "$AK_PROJECT" && ak setup --yes --no-ruvnet-brain --aqe-embedding-mode local) | tee "$RUNNER_TEMP/setup.log" (cd "$AK_PROJECT" && ak x aqe-embedding verify --json) | tee "$RUNNER_TEMP/embedding-proof.json" + # shellcheck disable=SC2016 # JavaScript template literals are evaluated by Node. node --input-type=module -e ' import fs from "node:fs"; import path from "node:path"; diff --git a/docs/archive/2026-09-29-native-learning-trace.md b/docs/archive/2026-09-29-native-learning-trace.md new file mode 100644 index 00000000..00aef9c2 --- /dev/null +++ b/docs/archive/2026-09-29-native-learning-trace.md @@ -0,0 +1,32 @@ +# Hosted macOS learning resolution trace + +## Status and inputs + +Captured 2026-09-29. This is an observation, not a native-readiness or causal verdict. + +- [Workflow run](https://github.com/pacphi/agentic-kit/actions/runs/36567908852), [macOS job](https://github.com/pacphi/agentic-kit/actions/runs/36567908852/job/109404326296), [artifact](https://github.com/pacphi/agentic-kit/actions/runs/36567908852/artifacts/11031869828). +- Source `ed8f4cb96836e1911aae9a89bbb3f015f033e115`; hook SHA-256 `98d9cc2c54e570eb18e24af83a7c401779c6eec12d58fe740e8c1a7622276dae`. +- macOS arm64, Node 22.23.2; Ruflo 3.48.0 and Agentic QE 3.14.5 installed in a disposable CI prefix. +- The kit's `sync --no-upgrade` healing step ran before `node bin/agentic-kit.mjs status --refresh=live --only learning`. This is a post-heal observation, not a pristine npm-tree measurement. +- The hook follows [vidaunited's resolution-hook proposal](https://github.com/ruvnet/ruflo/issues/2885#issuecomment-5867331087), with metadata-only JSONL and explicit artifact paths. + +## Observations + +The artifact contains 22 records: 16 preload starts and six package-resolution records across three processes. The two distinct package roots, with the runner-specific prefix omitted, are: + +```text +@huggingface/transformers@3.8.1 + npm-prefix/lib/node_modules/ruflo/node_modules/@claude-flow/cli/node_modules/@huggingface/transformers +onnxruntime-node@1.21.0 + npm-prefix/lib/node_modules/ruflo/node_modules/@claude-flow/cli/node_modules/@huggingface/transformers/node_modules/onnxruntime-node +``` + +The learning step invokes `ruflo neural train -p coordination -e 50`. Its output contains the default `fp32` dtype warning and `libc++abi` / `mutex lock failed: Invalid argument`. The outer kit command exited 1. The trace and receipt were retained despite that failure; the existing `continue-on-error` boundary explains the green macOS job. + +The separate clean-setup job failed at an AQE embedding process probe, and external link checking failed. Those results are not evidence that the trace worked or that setup is healthy. The trace was enabled only in the macOS learning step. + +## What this establishes + +The old package roots were resolved during the failing traced step. It remains unproved which installation/healing/dependency action introduced them and whether they caused the mutex failure. Resolution observations are not an exhaustive native-module census. A start-only artifact would establish only that preload reached a registration attempt. `learningExitCode` records the outer kit command, not a separately captured native Ruflo exit. + +Synthetic ESM/CommonJS, nested-copy, child-inheritance, unknown-version and failed-log-target tests passed. A later fixture-only correction uses file URLs for Windows preload paths and adds a real space-path test; it does not change the hook bytes captured here. The exact sanitized upstream comment was awaiting maintainer approval at this capture. No upstream message or user-global installation is implied by this record. diff --git a/docs/archive/2026-09-29-plan-upstream-native-trace.md b/docs/archive/2026-09-29-plan-upstream-native-trace.md new file mode 100644 index 00000000..b2391e68 --- /dev/null +++ b/docs/archive/2026-09-29-plan-upstream-native-trace.md @@ -0,0 +1,19 @@ +# Upstream native trace plan + +## Status + +**Implemented; final integration pending** — Captured 2026-09-29. The hook and nightly workflow passed independent review and all eight local gates. Hosted macOS run 36567908852 captured package resolutions and the learning failure. The Windows preload fixture was corrected to use a file URL and independently reviewed. Final documentation/PR CI and the exact upstream comment approval remain separate gates. + +## Scope + +Add a passive Node resolution hook for the nightly macOS learning probe. The hook records each resolved `@huggingface/transformers`, `@xenova/transformers`, and `onnxruntime-node` package root once per process, including its on-disk version when readable. It records no command arguments, environment values, or prompt content. The nightly workflow edit and native CI run belong to the integration owner. + +## Steps + +1. Add focused child-process tests with synthetic packages for ESM and CommonJS resolution, nested copies, missing/malformed versions, and an unusable log path. +2. Implement `scripts/trace-ort.mjs` from vidaunited's hook in [ruflo issue 2885](https://github.com/ruvnet/ruflo/issues/2885#issuecomment-5867331087). Require `TRACE_ORT_LOG` to be an absolute, explicit target. Emit a hook-start receipt and one JSONL record per package root per process. Observation failures must not change the observed command's exit result. +3. Run the focused guarded test and static checks, then provide exact nightly workflow hunks to the integration owner. Retain the trace, source/version, and exit receipt on failure. + +## Acceptance and limits + +The synthetic tests must prove observed resolution behavior without downloads or native ORT. The hosted macOS learning trace is captured in the accompanying dated evidence record. An empty trace is not proof that no relevant package loaded; a start receipt proves that preload reached the registration attempt, not that registration succeeded. Resolution hooks do not prove native addon teardown or capture packages loaded before registration. The receipt records the outer kit command exit, not necessarily Ruflo's raw exit. diff --git a/docs/archive/README.md b/docs/archive/README.md index 157b2176..614a56e4 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -163,6 +163,8 @@ reconfirmed by this metadata audit. The per-file inventory and limitations are r | [2026-09-28-plan-docs-taxonomy-and-archive.md](2026-09-28-plan-docs-taxonomy-and-archive.md) | `docs/plans/2026-09-28-docs-taxonomy-and-archive.md` | Finished documentation taxonomy and archive plan | Implemented in [PR #264](https://github.com/pacphi/agentic-kit/pull/264) and [PR #266](https://github.com/pacphi/agentic-kit/pull/266). Current layout rules live in the [docs index](../README.md) and [layout guard](../../scripts/docs-layout.mjs); this plan records the build steps. | | [2026-09-28-plan-sonnet-5-5-routing-refresh.md](2026-09-28-plan-sonnet-5-5-routing-refresh.md) | `docs/plans/2026-09-28-sonnet-5-5-routing-refresh.md` | Routing and pricing research with the implemented model-catalog decision | Implemented in [PR #268](https://github.com/pacphi/agentic-kit/pull/268). The operative tier decision is in [ADR-0006](../adr/0006-primary-host-and-ambidextrous-mirroring.md); prices and benchmarks here are dated evidence. | | [2026-09-28-plan-dashboard-refresh.md](2026-09-28-plan-dashboard-refresh.md) | `docs/plans/2026-09-28-dashboard-refresh.md` | Completed V3 implementation plan | Scoped implementation and independent review through `d2c1833b`, including native plain-folder evidence and paused Activity timestamps. Full branch gates, PR CI and develop integration remain separate gates at capture. | +| [2026-09-29-plan-upstream-native-trace.md](2026-09-29-plan-upstream-native-trace.md) | `docs/plans/2026-09-29-upstream-native-trace.md` | Completed C3 instrumentation plan | Hook, workflow and native trace captured; final PR integration and exact upstream-post approval remain separate gates at capture. | +| [2026-09-29-native-learning-trace.md](2026-09-29-native-learning-trace.md) | — (new) | Hosted macOS package-resolution evidence | Binds the observed 3.8.1/1.21.0 roots and learning failure to one source/run; introduction and causality remain unproved. | ## Naming convention diff --git a/scripts/trace-ort.mjs b/scripts/trace-ort.mjs new file mode 100644 index 00000000..7c5afcac --- /dev/null +++ b/scripts/trace-ort.mjs @@ -0,0 +1,77 @@ +// Passive adaptation of vidaunited's Node resolution hook: +// https://github.com/ruvnet/ruflo/issues/2885#issuecomment-5867331087 +// Enable only with an explicit absolute TRACE_ORT_LOG and NODE_OPTIONS=--import=. +import * as moduleApi from 'node:module'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SOURCE = 'ruvnet/ruflo#2885:issuecomment-5867331087'; +const log = process.env.TRACE_ORT_LOG; +const seen = new Set(); +let warned = false; + +function warn(message) { + if (warned) return; + warned = true; + try { process.stderr.write(`[trace-ort] ${message}\n`); } catch { /* observation is best effort */ } +} + +function append(record) { + try { + fs.appendFileSync(log, `${JSON.stringify({ schema: 1, pid: process.pid, ...record })}\n`, { flag: 'a' }); + } catch { + warn('log unavailable; trace artifact is incomplete'); + } +} + +function packageAt(file) { + const parts = path.normalize(file).split(path.sep); + for (let i = parts.length - 2; i >= 0; i--) { + if (parts[i] !== 'node_modules') continue; + const first = parts[i + 1]; + const scoped = first === '@huggingface' || first === '@xenova'; + const name = scoped ? `${first}/${parts[i + 2]}` : first; + if (!['@huggingface/transformers', '@xenova/transformers', 'onnxruntime-node'].includes(name)) continue; + const end = i + (scoped ? 3 : 2); + if (parts.length <= end) continue; + return { name, root: parts.slice(0, end).join(path.sep) || path.sep }; + } + return null; +} + +function versionAt(root) { + try { + const metadata = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + return typeof metadata.version === 'string' && metadata.version.length <= 128 && metadata.version.length > 0 + ? metadata.version : 'unknown'; + } catch { return 'unknown'; } +} + +function note(url) { + if (!url?.startsWith('file:')) return; + const found = packageAt(fileURLToPath(url)); + if (!found || seen.has(found.root)) return; + seen.add(found.root); + const rootTruncated = found.root.length > 4096; + append({ type: 'package', name: found.name, version: versionAt(found.root), + root: found.root.slice(0, 4096), ...(rootTruncated ? { rootTruncated: true } : {}) }); +} + +if (typeof log !== 'string' || !path.isAbsolute(log)) { + warn('log target missing or relative; set absolute TRACE_ORT_LOG'); +} else if (typeof moduleApi.registerHooks !== 'function') { + warn('Node module.registerHooks unavailable; requires Node 22.15 or newer'); +} else { + append({ type: 'start', hook: 'trace-ort/1', source: SOURCE, node: process.version, + platform: process.platform, arch: process.arch }); + try { + moduleApi.registerHooks({ + resolve(specifier, context, nextResolve) { + const result = nextResolve(specifier, context); + try { note(result.url); } catch { warn('resolution observation failed; trace artifact is incomplete'); } + return result; + }, + }); + } catch { warn('hook registration failed; trace artifact is incomplete'); } +} diff --git a/tests/kit/trace-ort.test.mjs b/tests/kit/trace-ort.test.mjs new file mode 100644 index 00000000..58e62773 --- /dev/null +++ b/tests/kit/trace-ort.test.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { test } from 'node:test'; +import { tempDir } from './helpers/temp-dir.mjs'; +import { spawnEnv } from './helpers/home-sandbox.mjs'; + +const hook = new URL('../../scripts/trace-ort.mjs', import.meta.url).href; + +function pkg(root, name, version, { commonjs = false, malformed = false } = {}) { + const dir = path.join(root, 'node_modules', ...name.split('/')); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), malformed ? '{bad' : JSON.stringify({ name, version, main: 'index.js', type: commonjs ? 'commonjs' : 'module' })); + fs.writeFileSync(path.join(dir, 'index.js'), commonjs ? 'module.exports = 1;\n' : 'export default 1;\n'); + return dir; +} + +function run(root, source, log = path.join(root, 'trace.jsonl'), hookUrl = hook) { + const home = path.join(root, 'home'); + const entry = path.join(root, 'entry.mjs'); + fs.writeFileSync(entry, source); + const env = spawnEnv(home, { NODE_OPTIONS: `--import=${hookUrl}`, TRACE_ORT_LOG: log }); + const child = spawnSync(process.execPath, [entry], { cwd: root, env, encoding: 'utf8' }); + const records = fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : []; + return { child, records }; +} + +test('records CJS and ESM resolution once per distinct package root', (t) => { + const root = tempDir('trace-ort', t); + const huggingface = pkg(root, '@huggingface/transformers', '4.3.0'); + const ort = pkg(root, 'onnxruntime-node', '1.30.0', { commonjs: true }); + const nested = path.join(root, 'nested'); + const old = pkg(nested, '@huggingface/transformers', '3.8.1'); + const { child, records } = run(root, `import '@huggingface/transformers'; +import '@huggingface/transformers'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +require('onnxruntime-node'); +require('onnxruntime-node'); +const nestedRequire = createRequire(${JSON.stringify(path.join(nested, 'entry.cjs'))}); +nestedRequire('@huggingface/transformers'); +`); + assert.equal(child.status, 0, child.stderr); + assert.equal(records.filter((r) => r.type === 'start').length, 1); + assert.deepEqual(records.filter((r) => r.type === 'package').map((r) => [r.name, r.version, r.root]).sort(), [ + ['@huggingface/transformers', '3.8.1', old], + ['@huggingface/transformers', '4.3.0', huggingface], + ['onnxruntime-node', '1.30.0', ort], + ].sort()); + assert.ok(records.every((r) => r.pid === records[0].pid)); +}); + +test('reports unknown version without executing package metadata', (t) => { + const root = tempDir('trace-ort-version', t); + pkg(root, '@xenova/transformers', undefined); + const { child, records } = run(root, "import '@xenova/transformers';\n"); + assert.equal(child.status, 0, child.stderr); + assert.equal(records.find((r) => r.type === 'package')?.version, 'unknown'); +}); + +test('an unusable or absent log target does not change the command result', (t) => { + const root = tempDir('trace-ort-failure', t); + pkg(root, 'onnxruntime-node', '1.30.0', { commonjs: true }); + const source = "import { createRequire } from 'node:module'; createRequire(import.meta.url)('onnxruntime-node');\n"; + const invalid = run(root, source, path.join(root, 'missing', 'trace.jsonl')); + assert.equal(invalid.child.status, 0, invalid.child.stderr); + assert.match(invalid.child.stderr, /trace-ort.*log/i); + const absent = run(root, source, ''); + assert.equal(absent.child.status, 0, absent.child.stderr); + assert.match(absent.child.stderr, /trace-ort.*log/i); +}); + +test('NODE_OPTIONS traces inherited child processes with separate start receipts', (t) => { + const root = tempDir('trace-ort-child', t); + pkg(root, 'onnxruntime-node', '1.21.0', { commonjs: true }); + const childFile = path.join(root, 'child.cjs'); + fs.writeFileSync(childFile, "require('onnxruntime-node');\n"); + const { child, records } = run(root, `import { spawnSync } from 'node:child_process'; +const result = spawnSync(process.execPath, [${JSON.stringify(childFile)}], { encoding: 'utf8' }); +if (result.status !== 0) process.exit(result.status || 1); +`); + assert.equal(child.status, 0, child.stderr); + const starts = records.filter((r) => r.type === 'start'); + assert.equal(starts.length, 2); + assert.equal(new Set(starts.map((r) => r.pid)).size, 2); + assert.equal(records.filter((r) => r.type === 'package').length, 1); + assert.equal(records.find((r) => r.type === 'package')?.version, '1.21.0'); +}); + +test('preloads a hook from a path containing spaces', (t) => { + const root = tempDir('trace ort space', t); + const hookDir = path.join(root, 'hook with spaces'); + fs.mkdirSync(hookDir); + const copiedHook = path.join(hookDir, 'trace ort.mjs'); + fs.copyFileSync(fileURLToPath(hook), copiedHook); + pkg(root, 'onnxruntime-node', '1.30.0', { commonjs: true }); + const { child, records } = run(root, + "import { createRequire } from 'node:module'; createRequire(import.meta.url)('onnxruntime-node');\n", + path.join(root, 'trace output.jsonl'), pathToFileURL(copiedHook).href); + assert.equal(child.status, 0, child.stderr); + assert.equal(records.filter((r) => r.type === 'start').length, 1); + assert.equal(records.find((r) => r.type === 'package')?.version, '1.30.0'); +});