diff --git a/examples/coordination-inventory/README.md b/examples/coordination-inventory/README.md new file mode 100644 index 000000000..97789209e --- /dev/null +++ b/examples/coordination-inventory/README.md @@ -0,0 +1,150 @@ +# Read-only coordination inventory + +One local JSON report joins declared task IDs and parent IDs, heartbeat age, +optional process metadata, OS RAM, declared resource leases and path/import +warnings. It reuses ECC's orchestration status parser and agent-proximity +scoring. It does not start a server or send messages. + +From the repository root, with Node 18 or newer and no dependency install: + +```sh +node scripts/coordination-inventory.js --manifest examples/coordination-inventory/manifest.json --now 2026-09-08T06:30:00.000Z +node scripts/coordination-inventory.js --manifest examples/coordination-inventory/goals.json --now 2026-09-08T06:30:00.000Z +node scripts/coordination-inventory.js --coordination /path/to/coordination --live +node examples/coordination-inventory/evaluate.js +node --test tests/scripts/coordination-inventory.test.js +node --test tests/scripts/coordination-goals.test.js +node examples/coordination-inventory/benchmark.js +``` + +The first command uses a **synthetic** fixed-time fixture. It demonstrates a +parent/child pair with an import dependency, a stale heartbeat and conflicting +browser ownership declarations. The file grants no browser access. + +`--coordination` reads direct child directories with `STATUS.md` or legacy +`status.md`. Structured `- State:` and UTC `- Updated:` fields use the existing +orchestration parser. Freeform status has unknown state/heartbeat; modification +time is reported separately. Symlink task directories and final status files +are not followed. Unreadable child directories make discovery partial; an +unavailable root is explicit, not an empty successful inventory. + +`--live` samples OS total/free bytes and, for explicitly declared positive PIDs, +`ps` PID, parent PID, RSS, elapsed time and state flags on macOS/Linux. It uses a +two-second timeout without shell expansion. It never reads argv, environment, +transcripts or process executable names. Unsupported platforms and inaccessible +process telemetry are explicit. Free memory is not macOS memory pressure or a +safe allocation budget. No PID supplied means no process scan. PID identity and +PID reuse are not verified. An old heartbeat means inspection is useful; it +cannot prove that a process is stuck. + +## Manifest contract + +See `manifest.json`. Version 1 accepts repositories with IDs and source snippet +maps, tasks with IDs, optional parent IDs, repository IDs, repo-relative declared +paths, optional PIDs/status/UTC heartbeat times, and leases with resource, owner +and UTC expiry. Parent IDs can reference an external orchestrator. Repository +IDs scope warnings across separate checkouts; use the same logical repo ID for +workers editing the same repository. Duplicate task IDs are rejected, including +when combining a manifest with discovered status files. + +Bounds: 1 MiB JSON, 64 tasks/repositories, 128 paths per task, 128 snippets per +repository, 1 KiB per snippet and 32 KiB snippets total, 128 leases. Snippets can +be just import statements plus empty entries for known targets. They are parsed +as text, never executed or emitted in the report. An aggregate comparison budget +rejects excessive pair/graph work; split large inputs into smaller inventories. +Only provide nonsensitive metadata in task IDs, status fields and paths. + +Every result identifies coverage. Paths are declared intentions, not a scan of +all current edits. Only supplied relative JS/TS imports resolve. Missing paths +or source snippets mean incomplete visibility. Existing control-pane default +working sets use committed `base...HEAD` differences and can miss dirty and +untracked work; this example does not claim to fix that separate adapter. + +Leases are owner declarations, not enforced locks. Expired entries are visible +but excluded from simultaneous-owner conflicts. An unexpired entry does not +prove the owner is alive or authorized. The caller supplies those declarations; +the inventory never acquires, renews or releases leases. No lease records means +ownership is unknown. No pause, steer, kill, settings change or allocation occurs. + +## Declared goals and sessions + +Optional `goals` and `sessions` collections add observations to the v1 manifest. +Each accepts at most 64 records, within the same 1 MiB total input budget. IDs +are unique within each collection. A goal accepts `id`, optional `taskId`, +`kind` (`native` or `unknown`), `status` (`active`, `complete`, `blocked` or +`unknown`), and optional UTC `updatedAt`. A session accepts `id`, optional +`taskId`/`goalId`, `status` (`open`, `closed` or `unknown`) and optional UTC +`updatedAt`. Omitted kind/status defaults to `unknown`; invalid supplied enum +values and scalar collection types are rejected. Supplied non-null links must +reference a supplied task or goal. These are associations, not exclusive owners; +multiple sessions may reference one goal without counting that goal twice. + +`goals.json` is synthetic: three open sessions reference one active goal, one +completed goal and one missing goal declaration. At its fixed example time the +report has one `freshActiveNativeGoalDeclarations` and one +`openSessionsWithoutGoalDeclaration`. An open session linked to a completed goal +stays open while the goal stays complete. Neither status overwrites the other. + +Every goal/session record has `authority: "declared-only"`. Even `kind: "native"` +is the caller's claim, not a native goal-tool verification. Supply a nonsensitive +observation derived from an authorized tool receipt; do not paste raw tool blobs, +objective text, transcripts or credentials. Unrecognized fields are omitted from +reports. The inventory never reads private thread stores or automatically imports +GOAL-STATE files. The caller retains the receipt and its provenance separately. + +`coverage.goals` and `coverage.sessions` distinguish `missing` collections from +`declared-only` collections, including explicitly empty arrays. Neither proves +global absence. `activity` contains declaration counts by status, native-kind +declaration counts, open sessions without goal links and the number of fresh +active native-kind declarations. These count records, not task associations or +verified running processes. No goal is inferred from a terminal, task `status`, +heartbeat, PID, resource lease or status-file modification time. + +Freshness uses the existing five-minute observation threshold: exactly five +minutes old is fresh, older is stale, future observations are `clock-skew`, and +missing timestamps are unknown. It does not rewrite declared state, and even a +fresh active declaration does not prove current execution. Goal/session state +never suppresses overlap warnings or expands process probing. Ownership remains +in declared paths and resource leases; no pause, message, steer or permission +grant is triggered by any count or warning. + +Existing task, warning, resource and lease outputs are unchanged. The new arrays, +activity summary and coverage keys are additive v1 output; consumers that reject +unknown fields need updating. Older consumers will ignore these declarations. +This remains a source-checkout example; these commands/examples are not claimed +to be shipped in the npm package. + +## Evaluation and limitations + +Eight authored synthetic pairs compare an exact-path baseline with ECC's +existing overlap/import/tree heuristic, using threshold 0.35. Tree proximity +alone does not trigger a warning. The score is not a calibrated probability. + +| Detector | True positive | False positive | True negative | False negative | +| --- | ---: | ---: | ---: | ---: | +| Exact path | 1 | 0 | 4 | 3 | +| Path and import | 2 | 1 | 3 | 2 | + +The extra detection is a direct relative import. A commented import produces +one false positive; an alias and a cross-artifact relationship are missed. These +are explicit characterization cases, not a held-out benchmark. Source parsing +is regex-based and incomplete; hashed visual coordinates, semantic/PCA proximity, +predictive proximity and 85% conflict reduction are not validated here. + +Next experiment: freeze 20 paired isolated tasks and collect declared intent, +actual changed paths and import edges in shadow mode. Have a human label which +pairs needed coordination before inspecting scores. Report precision, recall, +alerts per pair and p50/p95 overhead against exact-path and isolation-only +baselines. After that, randomize warning display and measure conflict/rework +rate with the same task mix. No automatic pause until warning usefulness and +ownership enforcement are separately established. + +The dependency-free `benchmark.js` characterizes the legacy fixture, declared +fixture and 64-goal/64-session limit with five warmup batches and 31 measured +batches of ten inventory builds each. It reports median/p95 batch-average +milliseconds, sample counts, fixed input hashes and the same eight overlap +controls. It excludes process startup and CLI I/O; the declaration-limit workload +is not a worst-case graph benchmark. Compare identical input hashes, Node runtime +and parameters before/after on the same machine. Historical one-shot elapsed +time is not a comparable speedup baseline. No performance improvement or conflict +reduction is asserted from merely adding these observations. diff --git a/examples/coordination-inventory/benchmark.js b/examples/coordination-inventory/benchmark.js new file mode 100644 index 000000000..c1171aeba --- /dev/null +++ b/examples/coordination-inventory/benchmark.js @@ -0,0 +1,58 @@ +#!/usr/bin/env node +'use strict'; +const { performance } = require('node:perf_hooks'); +const { createHash } = require('node:crypto'); +const { buildInventory } = require('../../scripts/lib/coordination-inventory'); +const legacy = require('./manifest.json'); +const declared = require('./goals.json'); +const controls = require('./fixtures.json'); +const now = '2026-09-08T06:30:00.000Z'; +const parameters = { warmupBatches: 5, samples: 31, iterationsPerSample: 10 }; +const atLimit = { ...legacy, + goals: Array.from({ length: 64 }, (_, i) => ({ id: `g${i}`, taskId: 'a', + kind: 'native', status: 'active', updatedAt: now })), + sessions: Array.from({ length: 64 }, (_, i) => ({ id: `s${i}`, taskId: 'a', + goalId: `g${i}`, status: 'open', updatedAt: now })) +}; + +function measure(name, manifest) { + const batch = () => { + for (let i = 0; i < parameters.iterationsPerSample; i += 1) buildInventory(manifest, { now }); + }; + for (let i = 0; i < parameters.warmupBatches; i += 1) batch(); + const samples = Array.from({ length: parameters.samples }, () => { + const start = performance.now(); batch(); + return (performance.now() - start) / parameters.iterationsPerSample; + }).sort((a, b) => a - b); + const report = buildInventory(manifest, { now }); + const input = JSON.stringify(manifest); + return { name, inputBytes: Buffer.byteLength(input), + inputSha256: createHash('sha256').update(input).digest('hex'), + medianMs: samples[Math.floor(samples.length / 2)], + p95Ms: samples[Math.ceil(samples.length * 0.95) - 1], samplesMs: samples, + warnings: report.warnings, activity: report.activity ?? null }; +} + +const rows = controls.map(control => { + const [a, b] = control.manifest.tasks; + return { id: control.id, needsReview: control.needsReview, + exactPath: a.repoId === b.repoId && a.paths.some(p => b.paths.includes(p)), + pathAndImport: buildInventory(control.manifest, { now }).warnings.length > 0 }; +}); +const matrix = detector => rows.reduce((result, row) => { + const key = row.needsReview ? (row[detector] ? 'truePositive' : 'falseNegative') + : (row[detector] ? 'falsePositive' : 'trueNegative'); + return { ...result, [key]: result[key] + 1 }; +}, { truePositive: 0, falsePositive: 0, trueNegative: 0, falseNegative: 0 }); +const report = { + version: 1, mode: 'synthetic-local-characterization', node: process.version, + platform: process.platform, parameters, + workloads: [measure('legacy', legacy), measure('declared', declared), measure('declaration-limit', atLimit)], + overlapControls: { dataset: 'eight-authored-synthetic-pairs-v1', rows, + baseline: matrix('exactPath'), candidate: matrix('pathAndImport') }, + limits: ['Batch average buildInventory time excludes process startup and CLI I/O.', + 'Declaration-limit uses 64 goals and 64 sessions; it is not a maximum graph-work benchmark.', + 'Timing is machine-dependent; no production conflict reduction or 85% improvement claim.', + 'Declarations are caller input, not verified native goal or session execution.'] +}; +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/examples/coordination-inventory/evaluate.js b/examples/coordination-inventory/evaluate.js new file mode 100644 index 000000000..6449b52ea --- /dev/null +++ b/examples/coordination-inventory/evaluate.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node +'use strict'; +const { performance } = require('node:perf_hooks'); +const { buildInventory } = require('../../scripts/lib/coordination-inventory'); +const cases = require('./fixtures.json'); +function matrix() { return { truePositive: 0, falsePositive: 0, trueNegative: 0, falseNegative: 0 }; } +function add(m, expected, actual) { m[expected ? actual ? 'truePositive' : 'falseNegative' : actual ? 'falsePositive' : 'trueNegative'] += 1; } +const baseline = matrix(); const candidate = matrix(); +const started = performance.now(); +const rows = cases.map(c => { + const report = buildInventory(c.manifest, { now: '2026-09-08T06:30:00.000Z' }); + const [a,b] = c.manifest.tasks; + const exactPath = a.repoId === b.repoId && a.paths.some(p => b.paths.includes(p)); + const warning = report.warnings.length > 0; + add(baseline,c.needsReview,exactPath); add(candidate,c.needsReview,warning); + return { id: c.id, needsReview: c.needsReview, exactPath, pathAndImport: warning }; +}); +process.stdout.write(`${JSON.stringify({ version:1, dataset:'eight-authored-synthetic-pairs-v1', rows, baseline, candidate, + elapsedMs: performance.now()-started, conclusion:'Fixture detection only. Not a measured reduction in conflicts or validation of semantic/PCA proximity.' },null,2)}\n`); diff --git a/examples/coordination-inventory/fixtures.json b/examples/coordination-inventory/fixtures.json new file mode 100644 index 000000000..84dddfde5 --- /dev/null +++ b/examples/coordination-inventory/fixtures.json @@ -0,0 +1,255 @@ +[ + { + "id": "same-path", + "needsReview": true, + "manifest": { + "version": 1, + "repositories": [ + { + "id": "repo", + "sources": {} + } + ], + "tasks": [ + { + "id": "a", + "repoId": "repo", + "paths": [ + "src/a.js" + ] + }, + { + "id": "b", + "repoId": "repo", + "paths": [ + "src/a.js" + ] + } + ], + "leases": [] + } + }, + { + "id": "direct-relative-import", + "needsReview": true, + "manifest": { + "version": 1, + "repositories": [ + { + "id": "repo", + "sources": { + "src/a.js": "require('../lib/b')", + "lib/b.js": "" + } + } + ], + "tasks": [ + { + "id": "a", + "repoId": "repo", + "paths": [ + "src/a.js" + ] + }, + { + "id": "b", + "repoId": "repo", + "paths": [ + "lib/b.js" + ] + } + ], + "leases": [] + } + }, + { + "id": "independent", + "needsReview": false, + "manifest": { + "version": 1, + "repositories": [ + { + "id": "repo", + "sources": {} + } + ], + "tasks": [ + { + "id": "a", + "repoId": "repo", + "paths": [ + "src/a.js" + ] + }, + { + "id": "b", + "repoId": "repo", + "paths": [ + "docs/guide.md" + ] + } + ], + "leases": [] + } + }, + { + "id": "same-directory", + "needsReview": false, + "manifest": { + "version": 1, + "repositories": [ + { + "id": "repo", + "sources": {} + } + ], + "tasks": [ + { + "id": "a", + "repoId": "repo", + "paths": [ + "src/a.js" + ] + }, + { + "id": "b", + "repoId": "repo", + "paths": [ + "src/b.js" + ] + } + ], + "leases": [] + } + }, + { + "id": "separate-repositories", + "needsReview": false, + "manifest": { + "version": 1, + "repositories": [ + { + "id": "repo", + "sources": {} + }, + { + "id": "other", + "sources": {} + } + ], + "tasks": [ + { + "id": "a", + "repoId": "repo", + "paths": [ + "src/a.js" + ] + }, + { + "id": "b", + "repoId": "other", + "paths": [ + "src/a.js" + ] + } + ], + "leases": [] + } + }, + { + "id": "comment-false-positive", + "needsReview": false, + "manifest": { + "version": 1, + "repositories": [ + { + "id": "repo", + "sources": { + "src/a.js": "// require('../lib/b')", + "lib/b.js": "" + } + } + ], + "tasks": [ + { + "id": "a", + "repoId": "repo", + "paths": [ + "src/a.js" + ] + }, + { + "id": "b", + "repoId": "repo", + "paths": [ + "lib/b.js" + ] + } + ], + "leases": [] + } + }, + { + "id": "alias-false-negative", + "needsReview": true, + "manifest": { + "version": 1, + "repositories": [ + { + "id": "repo", + "sources": { + "src/a.js": "import b from '@lib/b'", + "lib/b.js": "" + } + } + ], + "tasks": [ + { + "id": "a", + "repoId": "repo", + "paths": [ + "src/a.js" + ] + }, + { + "id": "b", + "repoId": "repo", + "paths": [ + "lib/b.js" + ] + } + ], + "leases": [] + } + }, + { + "id": "cross-artifact-false-negative", + "needsReview": true, + "manifest": { + "version": 1, + "repositories": [ + { + "id": "repo", + "sources": {} + } + ], + "tasks": [ + { + "id": "a", + "repoId": "repo", + "paths": [ + "specs/login.md" + ] + }, + { + "id": "b", + "repoId": "repo", + "paths": [ + "ui/login.html" + ] + } + ], + "leases": [] + } + } +] diff --git a/examples/coordination-inventory/goals.json b/examples/coordination-inventory/goals.json new file mode 100644 index 000000000..415eb0fb1 --- /dev/null +++ b/examples/coordination-inventory/goals.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "repositories": [{ "id": "repo", "sources": { "src/a.js": "require('../lib/b')", "lib/b.js": "" } }], + "tasks": [ + { "id": "a", "repoId": "repo", "paths": ["src/a.js"], "status": "running" }, + { "id": "b", "repoId": "repo", "paths": ["lib/b.js"], "parentId": "a" } + ], + "goals": [ + { "id": "goal-active", "taskId": "a", "kind": "native", "status": "active", "updatedAt": "2026-09-08T06:30:00.000Z" }, + { "id": "goal-complete", "taskId": "b", "kind": "native", "status": "complete", "updatedAt": "2026-09-08T06:30:00.000Z" } + ], + "sessions": [ + { "id": "session-active", "taskId": "a", "goalId": "goal-active", "status": "open", "updatedAt": "2026-09-08T06:30:00.000Z" }, + { "id": "session-open-complete", "taskId": "b", "goalId": "goal-complete", "status": "open" }, + { "id": "terminal-only", "status": "open" } + ], + "leases": [] +} diff --git a/examples/coordination-inventory/manifest.json b/examples/coordination-inventory/manifest.json new file mode 100644 index 000000000..c0657731e --- /dev/null +++ b/examples/coordination-inventory/manifest.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "repositories": [ + { + "id": "repo", + "sources": { + "src/a.js": "require('../lib/b')", + "lib/b.js": "" + } + } + ], + "tasks": [ + { + "id": "a", + "repoId": "repo", + "paths": [ + "src/a.js" + ], + "heartbeatAt": "2026-09-08T06:00:00Z" + }, + { + "id": "b", + "repoId": "repo", + "paths": [ + "lib/b.js" + ], + "parentId": "a" + } + ], + "leases": [ + { + "resource": "browser:chrome", + "owner": "root", + "expiresAt": "2026-09-08T07:00:00Z" + }, + { + "resource": "browser:chrome", + "owner": "worker", + "expiresAt": "2026-09-08T07:00:00Z" + } + ] +} diff --git a/scripts/coordination-inventory.js b/scripts/coordination-inventory.js new file mode 100644 index 000000000..ec655df1f --- /dev/null +++ b/scripts/coordination-inventory.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node +'use strict'; +const { normalizeManifest, buildInventory, collectResources, collectTaskFiles, readJson } = require('./lib/coordination-inventory'); + +function main(argv = process.argv.slice(2)) { + if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) { + process.stdout.write('Usage: node scripts/coordination-inventory.js [--manifest file.json] [--coordination directory] [--live] [--now ISO-UTC]\nRead-only JSON inventory. Live probes only OS memory and declared PIDs. No processes are executed from input.\n'); + return; + } + const options = {}; + for (let i = 0; i < argv.length; i += 1) { + const flag = argv[i]; + if (flag === '--live' && !options.live) options.live = true; + else if (['--manifest', '--coordination', '--now'].includes(flag) && !options[flag.slice(2)] && argv[i+1] && !argv[i+1].startsWith('--')) options[flag.slice(2)] = argv[++i]; + else throw new Error('Invalid inventory arguments. Use --help.'); + } + let manifest = options.manifest ? readJson(options.manifest) : { version: 1, tasks: [], repositories: [], leases: [] }; + let discovery = null; + if (options.coordination) { + discovery = collectTaskFiles(options.coordination); + // Duplicate IDs are rejected; never silently replace declared ownership. + manifest = { ...manifest, tasks: [...(manifest.tasks || []), ...discovery.tasks] }; + } + const normalized = normalizeManifest(manifest); + const resources = options.live ? collectResources(normalized.tasks) : undefined; + const report = buildInventory(manifest, { now: options.now, resources }); + if (discovery) report.discovery = { status: discovery.status, unreadable: discovery.unreadable }; + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} +if (require.main === module) { + try { main(); } catch { process.stderr.write('Inventory failed: invalid arguments or unreadable/invalid input. Use --help.\n'); process.exitCode = 1; } +} +module.exports = { main }; diff --git a/scripts/lib/agent-proximity/graph.js b/scripts/lib/agent-proximity/graph.js index 98bc05a3d..3d4c42ad6 100644 --- a/scripts/lib/agent-proximity/graph.js +++ b/scripts/lib/agent-proximity/graph.js @@ -25,9 +25,11 @@ function toRepoRel(repoRoot, absPath) { // Match relative specifiers only (./ or ../). Bare specifiers are node_modules // and never the target of an in-repo collision. +// Consume import whitespace once; a word boundary before `from` avoids +// overlapping whitespace quantifiers on incomplete import statements. const SPEC_PATTERNS = [ /require\(\s*['"](\.[^'"]+)['"]\s*\)/g, - /import\s+(?:[^'"]*?\s+from\s+)?['"](\.[^'"]+)['"]/g, + /import\s+(?!\s)(?:[^'"]*?\bfrom\s+)?['"](\.[^'"]+)['"]/g, /import\(\s*['"](\.[^'"]+)['"]\s*\)/g, /export\s+(?:\*|\{[^}]*\})\s+from\s+['"](\.[^'"]+)['"]/g ]; diff --git a/scripts/lib/coordination-inventory.js b/scripts/lib/coordination-inventory.js new file mode 100644 index 000000000..3835b0453 --- /dev/null +++ b/scripts/lib/coordination-inventory.js @@ -0,0 +1,261 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); +const { collisionRisk } = require('./agent-proximity/distance'); +const { buildDependencyGraphFromSources } = require('./agent-proximity/graph'); +const { parseWorkerStatus } = require('./orchestration-session'); + +const MAX_BYTES = 1024 * 1024; +const STALE_MS = 5 * 60 * 1000; +function invalid() { throw new Error('Invalid coordination input.'); } +function record(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(); + return value; +} +function list(value, max = 64) { + if (!Array.isArray(value) || value.length > max) invalid(); + return value; +} +function text(value, max = 200) { + if (typeof value !== 'string' || !value.length || value.length > max || [...value].some(c => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) invalid(); + return value; +} +function missing(value) { return value === null || value === undefined; } +function identifier(value) { + text(value); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(value) || ['__proto__', 'constructor', 'prototype'].includes(value)) invalid(); + return value; +} +function timestamp(value) { + text(value); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value) || !Number.isFinite(Date.parse(value))) invalid(); + const canonical = value.replace(/(?:\.(\d{1,3}))?Z$/, (_, fraction) => `.${(fraction || '').padEnd(3, '0')}Z`); + if (new Date(value).toISOString() !== canonical) invalid(); + return value; +} +function relativePath(value) { + const p = text(value, 1024).replace(/\\/g, '/').replace(/^\.\//, ''); + const parts = p.split('/'); + if (p.startsWith('/') || /^[A-Za-z]:/.test(p) || parts.some(x => !x || ['.', '..', '__proto__', 'constructor', 'prototype'].includes(x))) invalid(); + return p; +} +function unique(items, key) { + if (new Set(items.map(x => x[key])).size !== items.length) invalid(); + return items; +} +function normalizeTask(value) { + const t = record(value); + if (!missing(t.pid) && (!Number.isSafeInteger(t.pid) || t.pid <= 0)) invalid(); + const id = identifier(t.id); + const parentId = t.parentId ? identifier(t.parentId) : null; + if (parentId === id) invalid(); + return { + id, parentId, repoId: missing(t.repoId) ? null : identifier(t.repoId), + paths: [...new Set(list(t.paths || [], 128).map(relativePath))].sort(), + pid: t.pid ?? null, status: missing(t.status) ? 'unknown' : text(t.status), + heartbeatAt: missing(t.heartbeatAt) ? null : timestamp(t.heartbeatAt), + statusFileModifiedAt: missing(t.statusFileModifiedAt) ? null : timestamp(t.statusFileModifiedAt) + }; +} +function declarationStatus(value, allowed) { + if (value === undefined) return 'unknown'; + if (!allowed.includes(value)) invalid(); + return value; +} +function normalizeDeclarations(manifest, tasks) { + const taskIds = new Set(tasks.map(t => t.id)); + const link = (value, ids) => { + if (missing(value)) return null; + const id = identifier(value); + if (!ids.has(id)) invalid(); + return id; + }; + const common = value => ({ id: identifier(value.id), taskId: link(value.taskId, taskIds), + updatedAt: missing(value.updatedAt) ? null : timestamp(value.updatedAt) }); + const goals = unique(list(manifest.goals === undefined ? [] : manifest.goals).map(value => { + const g = record(value); + return { ...common(g), kind: declarationStatus(g.kind, ['native', 'unknown']), + status: declarationStatus(g.status, ['active', 'complete', 'blocked', 'unknown']) }; + }), 'id'); + const goalIds = new Set(goals.map(g => g.id)); + const sessions = unique(list(manifest.sessions === undefined ? [] : manifest.sessions).map(value => { + const s = record(value); + return { ...common(s), goalId: link(s.goalId, goalIds), + status: declarationStatus(s.status, ['open', 'closed', 'unknown']) }; + }), 'id'); + return { goals, sessions, declarationCoverage: { + goals: manifest.goals === undefined ? 'missing' : 'declared-only', + sessions: manifest.sessions === undefined ? 'missing' : 'declared-only' + } }; +} +function normalizeManifest(value) { + const m = record(value); + if (m.version !== 1 || Buffer.byteLength(JSON.stringify(m)) > MAX_BYTES) invalid(); + let sourceBytes = 0; + const repositories = unique(list(m.repositories ?? []).map(value => { + const r = record(value); const sourceEntries = Object.entries(record(r.sources ?? {})); + if (sourceEntries.length > 128) invalid(); + const entries = sourceEntries.map(([p, source]) => { + // Existing regex extractor is for snippets, not arbitrary full source files. + if (typeof source !== 'string' || Buffer.byteLength(source) > 1024) invalid(); + sourceBytes += Buffer.byteLength(source); + if (sourceBytes > 32768) invalid(); + return [relativePath(p), source]; + }); + if (new Set(entries.map(([p]) => p)).size !== entries.length) invalid(); + return { id: identifier(r.id), sources: Object.fromEntries(entries) }; + }), 'id'); + const tasks = unique(list(m.tasks).map(normalizeTask), 'id'); + const ids = new Set(repositories.map(r => r.id)); + if (tasks.some(t => t.repoId !== null && !ids.has(t.repoId))) invalid(); + const leases = list(m.leases ?? [], 128).map(value => { + const l = record(value); + return { resource: identifier(l.resource), owner: identifier(l.owner), expiresAt: timestamp(l.expiresAt) }; + }); + return { version: 1, repositories, tasks, leases, ...normalizeDeclarations(m, tasks) }; +} + +function heartbeat(value, nowMs) { + if (!value) return { state: 'unknown', ageMs: null }; + const ageMs = nowMs - Date.parse(value); + return { state: ageMs < 0 ? 'clock-skew' : ageMs > STALE_MS ? 'stale' : 'fresh', ageMs }; +} +function declarationInventory(manifest, nowMs) { + const observe = item => ({ ...item, authority: 'declared-only', freshness: heartbeat(item.updatedAt, nowMs) }); + const goals = manifest.goals.map(observe); + const sessions = manifest.sessions.map(observe); + const counts = (items, statuses) => Object.fromEntries(statuses.map(status => + [status, items.filter(item => item.status === status).length])); + const statuses = ['active', 'complete', 'blocked', 'unknown']; + const native = goals.filter(g => g.kind === 'native'); + return { goals, sessions, activity: { + declaredGoalsByStatus: counts(goals, statuses), + declaredNativeGoalsByStatus: counts(native, statuses), + declaredSessionsByStatus: counts(sessions, ['open', 'closed', 'unknown']), + openSessionsWithoutGoalDeclaration: sessions.filter(s => s.status === 'open' && s.goalId === null).length, + freshActiveNativeGoalDeclarations: native.filter(g => g.status === 'active' && g.freshness.state === 'fresh').length + } }; +} +function proximityWarnings(manifest) { + const warnings = []; + let workBudget = 200000; + for (const repo of manifest.repositories) { + const tasks = manifest.tasks.filter(t => t.repoId === repo.id && t.paths.length > 0).sort((a,b) => a.id < b.id ? -1 : 1); + if (tasks.length < 2) continue; + const parsed = buildDependencyGraphFromSources(repo.sources); + const graph = { ...parsed, adjacency: Object.assign(Object.create(null), parsed.adjacency) }; + const graphCost = 1 + graph.files.length + Object.values(graph.adjacency).reduce((sum, edges) => sum + edges.length, 0); + const pathPairs = tasks.reduce((sum, task, i) => sum + task.paths.length * tasks.slice(i + 1).reduce((n, other) => n + other.paths.length, 0), 0); + workBudget -= pathPairs * graphCost; + if (workBudget < 0) throw new Error('Inventory comparison budget exceeded; split the manifest.'); + for (let i = 0; i < tasks.length; i += 1) { + for (let j = i + 1; j < tasks.length; j += 1) { + const a = tasks[i]; const b = tasks[j]; + const score = collisionRisk({ files: a.paths.map(p => ({ path: p })) }, { files: b.paths.map(p => ({ path: p })) }, graph); + if (score.risk < 0.35) continue; + const reasons = []; + if (score.channels.overlap) reasons.push('path_overlap'); + if (score.channels.dependency) reasons.push('import_dependency'); + warnings.push({ repoId: repo.id, tasks: [a.id, b.id], reasons, score: score.risk, channels: score.channels, action: 'review-declared-work' }); + } + } + } + return warnings; +} +function buildInventory(input, options = {}) { + const m = normalizeManifest(input); + const now = timestamp(options.now || new Date().toISOString()); + const nowMs = Date.parse(now); + const resources = options.resources || { memory: null, processStatus: 'not-requested', processes: [] }; + const processes = new Map(resources.processes.map(p => [p.pid, p])); + const tasks = m.tasks.map(t => ({ ...t, heartbeat: heartbeat(t.heartbeatAt, nowMs), + process: processes.has(t.pid) ? { ...processes.get(t.pid), state: 'observed' } + : { state: t.pid && resources.processStatus === 'ok' ? 'not-observed' : 'unknown' } + })); + const leases = m.leases.map(l => ({ ...l, state: Date.parse(l.expiresAt) > nowMs ? 'unexpired' : 'expired', authority: 'declared-only' })); + const active = new Map(); + for (const l of leases.filter(l => l.state === 'unexpired')) { + active.set(l.resource, new Set([...(active.get(l.resource) || []), l.owner])); + } + const leaseConflicts = [...active].filter(([,owners]) => owners.size > 1) + .map(([resource,owners]) => ({ resource, owners: [...owners].sort() })).sort((a,b) => a.resource < b.resource ? -1 : 1); + return { + version: 1, mode: 'read-only', observedAt: now, tasks, leases, leaseConflicts, + ...declarationInventory(m, nowMs), + resources, warnings: proximityWarnings(m), + coverage: { tasks: 'declared-or-status-files-only', workingSets: 'declared-paths-only', imports: 'provided-source-map-relative-js-ts-only', leases: 'declared-only', processes: 'declared-pids-only', ...m.declarationCoverage }, + limits: ['Score is a heuristic, not a calibrated probability.', 'No warning does not establish collision-free work.', + 'Goal/session states and native kind are caller declarations, not verified execution or authority.', + 'Open sessions, task status and observed PIDs do not establish an active native goal.', + 'Missing declarations and empty lists do not establish global absence; fresh declarations do not prove current execution.', + 'Stale heartbeat is not proof of a stuck process; PID reuse is not resolved.', + 'Import regex may match comments and misses aliases, nonliteral and non-JS imports.', + 'No semantic/PCA proximity or conflict-reduction claim is validated.', + 'Leases are observations, not locks or permission grants.'] + }; +} + +function collectResources(tasks, deps = {}) { + const memory = { totalBytes: (deps.totalmem || os.totalmem)(), freeBytes: (deps.freemem || os.freemem)(), + source: 'os', note: 'OS free memory is not application headroom or macOS memory pressure.' }; + const pids = [...new Set(tasks.map(t => t.pid).filter(pid => Number.isSafeInteger(pid) && pid > 0))]; + if (!pids.length) return { memory, processStatus: 'not-requested', processes: [] }; + if (!['darwin', 'linux'].includes(deps.platform || process.platform)) return { memory, processStatus: 'unsupported', processes: [] }; + try { + const result = (deps.execFileSync || execFileSync)('ps', ['-p', pids.join(','), '-o', 'pid=,ppid=,rss=,etime=,stat='], + { encoding: 'utf8', timeout: 2000, maxBuffer: 65536, shell: false, stdio: ['ignore','pipe','pipe'] }); + const processes = String(result).split('\n').filter(l => l.trim()).map(line => { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+([\d:-]+)\s+([A-Za-z+<>NsElLW]+)$/); + if (!match) throw new Error('Invalid process metadata.'); + const values = match.slice(1,4).map(Number); + if (values.some(v => !Number.isSafeInteger(v)) || !pids.includes(values[0])) throw new Error('Invalid process metadata.'); + return { pid: values[0], parentPid: values[1], rssBytes: values[2] * 1024, elapsed: match[4], flags: match[5] }; + }); + return { memory, processStatus: 'ok', processes }; + } catch { return { memory, processStatus: 'unavailable', processes: [] }; } +} + +function readBounded(file, limit = MAX_BYTES) { + // Refuse symlink final components, devices and files beyond the byte budget. + const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile() || stat.size > limit) throw new Error('Input exceeds file limit.'); + const buffer = Buffer.alloc(limit + 1); + let size = 0; let count; + do { count = fs.readSync(fd, buffer, size, buffer.length - size, null); size += count; } while (count && size < buffer.length); + if (size > limit) throw new Error('Input exceeds file limit.'); + return { content: buffer.subarray(0,size).toString('utf8'), modifiedAt: stat.mtime.toISOString() }; + } finally { fs.closeSync(fd); } +} +function readJson(file) { + try { return JSON.parse(readBounded(file).content); } + catch (error) { throw new Error(error.message === 'Input exceeds file limit.' ? error.message : 'Cannot read coordination JSON.'); } +} +function collectTaskFiles(directory) { + try { + const entries = fs.readdirSync(directory, { withFileTypes: true }).filter(e => e.isDirectory() && !e.name.startsWith('.')).sort((a,b) => a.name < b.name ? -1 : 1); + if (entries.length > 64) throw new Error('Too many task directories.'); + const tasks = []; const unreadable = []; + for (const entry of entries) { + let loaded = false; + for (const name of ['STATUS.md', 'status.md']) { + try { + const data = readBounded(path.join(directory, entry.name, name), 65536); + const parsed = parseWorkerStatus(data.content); + let heartbeatAt = null; + try { if (parsed.updated) heartbeatAt = timestamp(parsed.updated); } catch { /* Unknown timestamp, not a heartbeat. */ } + tasks.push(normalizeTask({ id: entry.name, paths: [], status: parsed.state || 'unknown', heartbeatAt, statusFileModifiedAt: data.modifiedAt })); + loaded = true; break; + } catch { /* Try legacy lowercase status filename; report unreadable below. */ } + } + if (!loaded) unreadable.push(entry.name); + } + return { status: unreadable.length ? 'partial' : 'ok', tasks, unreadable }; + } catch { return { status: 'unavailable', tasks: [], unreadable: [] }; } +} + +module.exports = { normalizeManifest, buildInventory, collectResources, collectTaskFiles, readJson }; diff --git a/tests/hooks/stop-hooks-stdout.test.js b/tests/hooks/stop-hooks-stdout.test.js index 02a4bf3ce..3d0617c57 100644 --- a/tests/hooks/stop-hooks-stdout.test.js +++ b/tests/hooks/stop-hooks-stdout.test.js @@ -127,6 +127,21 @@ function assertStdoutContract(result, label) { } } +function formatSpawnFailure(result, elapsedMs) { + const token = value => typeof value === 'string' && /^[A-Z][A-Z0-9_]{0,47}$/.test(value) + ? value : null; + // Keep decoded UTF-8 byte counts, never stream contents or error messages. + const byteCount = value => typeof value === 'string' ? Buffer.byteLength(value, 'utf8') : null; + return JSON.stringify({ + elapsedMs: Number.isSafeInteger(elapsedMs) && elapsedMs >= 0 ? elapsedMs : null, + status: Number.isSafeInteger(result.status) ? result.status : null, + signal: token(result.signal), + errorCode: token(result.error && result.error.code), + stdoutBytes: byteCount(result.stdout), + stderrBytes: byteCount(result.stderr) + }); +} + // All registered Stop hooks (hooks/hooks.json). const STOP_HOOKS = [ ['stop:format-typecheck', 'scripts/hooks/stop-format-typecheck.js'], @@ -163,11 +178,13 @@ const realisticPayload = stopPayload(100 * 1024); for (const entry of hooksConfig.hooks.Stop) { if ( test(`${entry.id} registered wrapper flushes a 100KB Stop payload`, () => { + const startedAt = process.hrtime.bigint(); const result = runRegisteredStopHook(entry, realisticPayload); + const elapsedMs = Math.round(Number(process.hrtime.bigint() - startedAt) / 1e6); assert.strictEqual( result.status, 0, - `${entry.id}: expected exit 0, got ${result.status}: ${result.stderr}` + result.status === 0 ? undefined : `${entry.id}: expected exit 0; ${formatSpawnFailure(result, elapsedMs)}` ); assert.ok( result.stdout === realisticPayload, diff --git a/tests/scripts/coordination-goals.test.js b/tests/scripts/coordination-goals.test.js new file mode 100644 index 000000000..d2b9e1b14 --- /dev/null +++ b/tests/scripts/coordination-goals.test.js @@ -0,0 +1,124 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildInventory, normalizeManifest } = require('../../scripts/lib/coordination-inventory'); +const now = '2026-09-09T01:00:00.000Z'; +const fixture = () => ({ version: 1, + repositories: [{ id: 'repo', sources: {} }], + tasks: [{ id: 'worker', repoId: 'repo', paths: ['src/shared.js'], status: 'running', pid: 42 }, + { id: 'peer', repoId: 'repo', paths: ['src/shared.js'] }], leases: [] }); +const inventory = value => buildInventory(value, { now }); + +test('goal collections distinguish missing observations from explicit empty declarations', () => { + const missing = inventory(fixture()); + const empty = inventory({ ...fixture(), goals: [], sessions: [] }); + assert.equal(missing.coverage.goals, 'missing'); + assert.equal(missing.coverage.sessions, 'missing'); + assert.equal(empty.coverage.goals, 'declared-only'); + assert.equal(empty.coverage.sessions, 'declared-only'); + assert.deepEqual(missing.goals, []); + assert.deepEqual(missing.sessions, []); + assert.deepEqual(missing.activity, empty.activity); + assert.equal(missing.activity.freshActiveNativeGoalDeclarations, 0); +}); + +test('goal activity is never inferred from an open session, running task, heartbeat or observed PID', () => { + const input = fixture(); input.tasks[0].heartbeatAt = now; + input.sessions = [{ id: 'terminal', taskId: 'worker', status: 'open', updatedAt: now }]; + const report = buildInventory(input, { now, resources: { + memory: null, processStatus: 'ok', processes: [{ pid: 42, ppid: 1, rssBytes: 1024 }] } }); + assert.equal(report.tasks[0].process.state, 'observed'); + assert.equal(report.tasks[0].heartbeat.state, 'fresh'); + assert.equal(report.activity.declaredSessionsByStatus.open, 1); + assert.equal(report.activity.openSessionsWithoutGoalDeclaration, 1); + assert.deepEqual(report.activity.declaredGoalsByStatus, { active: 0, complete: 0, blocked: 0, unknown: 0 }); + assert.equal(report.coverage.goals, 'missing'); +}); + +test('goal and session declarations remain independent and count a shared goal once', () => { + const input = { ...fixture(), goals: [ + { id: 'active', taskId: 'worker', kind: 'native', status: 'active', updatedAt: now }, + { id: 'done', kind: 'native', status: 'complete', updatedAt: now }, + { id: 'unverified', status: 'active', updatedAt: now }, + { id: 'blocked', kind: 'native', status: 'blocked' }, { id: 'unknown' } + ], sessions: [ + { id: 'closed', goalId: 'active', status: 'closed' }, + { id: 'other', goalId: 'active', taskId: 'peer', status: 'open' }, + { id: 'open-done', goalId: 'done', status: 'open' }, { id: 'unknown-session' } + ] }; + const before = JSON.stringify(input); const report = inventory(input); + assert.deepEqual(report.activity.declaredGoalsByStatus, { active: 2, complete: 1, blocked: 1, unknown: 1 }); + assert.deepEqual(report.activity.declaredNativeGoalsByStatus, { active: 1, complete: 1, blocked: 1, unknown: 0 }); + assert.deepEqual(report.activity.declaredSessionsByStatus, { open: 2, closed: 1, unknown: 1 }); + assert.equal(report.activity.freshActiveNativeGoalDeclarations, 1); + assert.equal(report.activity.openSessionsWithoutGoalDeclaration, 0); + assert.equal(report.goals[2].kind, 'unknown'); + assert.equal(report.goals[4].status, 'unknown'); + assert.equal(report.sessions[3].status, 'unknown'); + assert.equal(report.goals[0].authority, 'declared-only'); + assert.equal(report.sessions[0].authority, 'declared-only'); + assert.equal(JSON.stringify(input), before); + assert.deepEqual(inventory(input), report); +}); + +test('goal freshness exposes missing stale future and boundary observations without rewriting status', () => { + const times = [null, '2026-09-09T00:54:59.999Z', '2026-09-09T01:00:00.001Z', + '2026-09-09T00:55:00.000Z', now]; + const report = inventory({ ...fixture(), goals: times.map((updatedAt, i) => + ({ id: `g${i}`, kind: 'native', status: 'active', updatedAt })) }); + assert.deepEqual(report.goals.map(g => g.freshness.state), ['unknown', 'stale', 'clock-skew', 'fresh', 'fresh']); + assert.equal(report.activity.declaredNativeGoalsByStatus.active, 5); + assert.equal(report.activity.freshActiveNativeGoalDeclarations, 2); + assert.ok(report.goals.every(g => g.status === 'active')); +}); + +test('goal declarations do not change existing task resource lease or overlap outputs', () => { + const base = fixture(); + base.leases = [{ resource: 'browser', owner: 'worker', expiresAt: now }]; + const legacy = inventory(base); + const report = inventory({ ...base, goals: [{ id: 'completed', status: 'complete' }], + sessions: [{ id: 'closed', status: 'closed', goalId: 'completed' }] }); + for (const key of ['tasks', 'warnings', 'resources', 'leases', 'leaseConflicts']) { + assert.deepEqual(report[key], legacy[key]); + } + assert.equal(report.warnings.length, 1); + assert.equal(report.warnings[0].action, 'review-declared-work'); +}); + +test('goal metadata drops objectives commands native blobs and other unrecognized fields', () => { + const report = inventory({ ...fixture(), goals: [{ id: 'g', objective: 'CANARY', + tool_result: { secret: 'CANARY' }, status: 'active', authority: 'CANARY' }], + sessions: [{ id: 's', goalId: 'g', command: 'CANARY', environment: 'CANARY' }] }); + assert.ok(!JSON.stringify(report).includes('CANARY')); + assert.equal(report.goals[0].authority, 'declared-only'); +}); + +test('goal input rejects malformed scalars enums dates duplicate IDs and dangling links', () => { + for (const collection of ['goals', 'sessions']) { + for (const value of [null, false, '', {}, 1]) { + assert.throws(() => normalizeManifest({ ...fixture(), [collection]: value }), /Invalid coordination input/); + } + for (const value of [null, false, [], 1, { id: 'bad/id' }, { id: '__proto__' }, + { id: 'x', status: null }, { id: 'x', status: true }, { id: 'x', status: 'running' }, + { id: 'x', updatedAt: '2026-02-30T00:00:00Z' }, { id: 'x', updatedAt: true }, + { id: 'x', taskId: 'missing' }, { id: 'x', taskId: 1 }]) { + assert.throws(() => normalizeManifest({ ...fixture(), [collection]: [value] }), /Invalid coordination input/); + } + assert.throws(() => normalizeManifest({ ...fixture(), [collection]: [{ id: 'same' }, { id: 'same' }] })); + } + for (const kind of [null, true, 1, 'verified', 'declared']) { + assert.throws(() => normalizeManifest({ ...fixture(), goals: [{ id: 'g', kind }] })); + } + assert.throws(() => normalizeManifest({ ...fixture(), sessions: [{ id: 's', goalId: 'missing' }] })); + assert.throws(() => normalizeManifest({ ...fixture(), sessions: [{ id: 's', goalId: 1 }] })); +}); + +test('goal and session cardinality and total input bounds remain enforced', () => { + const declarations = Array.from({ length: 64 }, (_, i) => ({ id: `item${i}` })); + const report = inventory({ ...fixture(), goals: declarations, sessions: declarations }); + assert.equal(report.goals.length, 64); assert.equal(report.sessions.length, 64); + for (const collection of ['goals', 'sessions']) { + assert.throws(() => inventory({ ...fixture(), [collection]: [...declarations, { id: 'extra' }] })); + } + assert.throws(() => inventory({ ...fixture(), goals: [{ id: 'g', ignored: 'x'.repeat(1024 * 1024) }] })); +}); diff --git a/tests/scripts/coordination-inventory.test.js b/tests/scripts/coordination-inventory.test.js new file mode 100644 index 000000000..ebcdb4728 --- /dev/null +++ b/tests/scripts/coordination-inventory.test.js @@ -0,0 +1,155 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { normalizeManifest, buildInventory, collectResources, collectTaskFiles, readJson } = require('../../scripts/lib/coordination-inventory'); +const now = '2026-09-08T06:30:00.000Z'; +const task = (id, paths, extra = {}) => ({ id, repoId: 'repo', paths, ...extra }); +const fixture = () => ({ version: 1, repositories: [{ id: 'repo', sources: { 'src/a.js': "require('../lib/b')", 'lib/b.js': '' } }], tasks: [task('a', ['src/a.js']), task('b', ['lib/b.js'])], leases: [] }); +const run = value => buildInventory(value, { now }); + +test('direct import warns when exact-path baseline would miss it; deterministic JSON', () => { + const f = fixture(); const before = JSON.stringify(f); const r = run(f); + assert.equal(r.warnings.length, 1); assert.deepEqual(r.warnings[0].reasons, ['import_dependency']); + assert.equal(r.warnings[0].channels.dependency, 1); + assert.equal(JSON.stringify(run(f)), JSON.stringify(r)); assert.equal(JSON.stringify(f), before); +}); +test('normalized exact paths warn, tree-only neighbors and cross-repo pairs do not', () => { + const f = fixture(); f.tasks[1].paths = ['./src/a.js']; + assert.deepEqual(run(f).warnings[0].reasons, ['path_overlap']); + f.tasks[1].paths = ['src/c.js']; assert.equal(run(f).warnings.length, 0); + f.repositories.push({ id: 'other', sources: {} }); f.tasks[1] = task('b', ['src/a.js'], { repoId: 'other' }); + assert.equal(run(f).warnings.length, 0); +}); +test('leases show owner, expiry, conflicts and do not grant authority', () => { + const f = fixture(); f.leases = [ + { resource: 'browser:chrome', owner: 'root', expiresAt: '2026-09-08T07:00:00Z' }, + { resource: 'browser:chrome', owner: 'worker', expiresAt: '2026-09-08T07:00:00Z' }, + { resource: 'browser:chrome', owner: 'old', expiresAt: now } + ]; const r = run(f); + assert.equal(r.leases[2].state, 'expired'); + assert.deepEqual(r.leaseConflicts, [{ resource: 'browser:chrome', owners: ['root', 'worker'] }]); + assert.equal(r.mode, 'read-only'); assert.equal(r.leases[0].authority, 'declared-only'); +}); +test('stale heartbeat is not a proven stuck process; absent/future telemetry stays unknown', () => { + const f = fixture(); f.tasks = [task('a', [], { heartbeatAt: '2026-09-08T06:00:00Z', pid: 12 }), task('b', [], { heartbeatAt: '2026-09-08T07:00:00Z' }), task('c', [])]; + const r = run(f); assert.equal(r.tasks[0].heartbeat.state, 'stale'); assert.equal(r.tasks[0].process.state, 'unknown'); + assert.equal(r.tasks[1].heartbeat.state, 'clock-skew'); assert.equal(r.tasks[2].heartbeat.state, 'unknown'); +}); +test('task parents, status and bounded observations survive without source payload', () => { + const f = fixture(); f.tasks[1].parentId = 'a'; f.tasks[0].status = 'running'; f.tasks[0].unexpectedSecret = 'CANARY_SECRET'; + f.repositories[0].sources['lib/b.js'] = 'CANARY_SOURCE'; + const r = run(f); assert.equal(r.tasks[1].parentId, 'a'); assert.equal(r.tasks[0].status, 'running'); + assert.ok(!JSON.stringify(r).includes('CANARY')); assert.equal(r.coverage.workingSets, 'declared-paths-only'); +}); +test('invalid shapes, IDs, paths, dates and missing repos fail closed', () => { + for (const mutate of [ + f => { f.version = 2; }, f => { f.tasks = null; }, f => { f.tasks.push(f.tasks[0]); }, + f => { f.tasks[0].paths = ['../escape']; }, f => { f.tasks[0].paths = ['/absolute']; }, + f => { f.tasks[0].paths = ['C:\\secret']; }, f => { f.tasks[0].paths = ['a/../b']; }, + f => { f.tasks[0].paths = ['__proto__']; }, f => { f.tasks[0].pid = '-1'; }, + f => { f.tasks[0].heartbeatAt = 'yesterday'; }, f => { f.tasks[0].repoId = 'absent'; }, + f => { f.repositories[0].sources = []; }, f => { f.tasks[0].id = '\n'; }, + f => { f.tasks[0].parentId = 'a'; }, f => { f.tasks = Array(65).fill(f.tasks[0]); }, + f => { f.leases = [{resource:'chrome',owner:'root',expiresAt:'bad'}]; } + ]) { const f = fixture(); mutate(f); assert.throws(() => normalizeManifest(f), /Invalid/); } +}); +test('process collection uses metadata-only argv, bounded timeout and no shell', () => { + let call; const r = collectResources([task('a', [], { pid: 12 })], { platform: 'darwin', totalmem: () => 1024, freemem: () => 512, execFileSync: (...args) => { call = args; return '12 1 32 01:30 S\n'; } }); + assert.equal(call[0], 'ps'); assert.deepEqual(call[1], ['-p','12','-o','pid=,ppid=,rss=,etime=,stat=']); + assert.equal(call[2].timeout, 2000); assert.equal(call[2].shell, false); + assert.equal(r.processes[0].rssBytes, 32768); assert.equal(r.memory.freeBytes, 512); +}); +test('unavailable, empty, malformed and unsupported process snapshots remain explicit', () => { + const tasks = [task('a', [], { pid: 12 })]; + let runnerCalls = 0; + const unsupportedDeps = { platform: 'win32', execFileSync: () => { runnerCalls += 1; return ''; } }; + const unsupported = collectResources(tasks, unsupportedDeps); + assert.equal(unsupported.processStatus, 'unsupported'); + assert.equal(buildInventory({ ...fixture(), tasks }, { now, resources: unsupported }).tasks[0].process.state, 'unknown'); + // Runner fixtures must select a supported platform independently of the host. + assert.equal(collectResources(tasks, { platform: 'darwin', execFileSync: () => { throw new Error('SECRET'); } }).processStatus, 'unavailable'); + assert.equal(collectResources(tasks, { platform: 'darwin', execFileSync: () => '' }).processStatus, 'ok'); + assert.equal(collectResources(tasks, { platform: 'darwin', execFileSync: () => 'bad row' }).processStatus, 'unavailable'); + assert.equal(collectResources([], unsupportedDeps).processStatus, 'not-requested'); + assert.equal(runnerCalls, 0); +}); +test('live process snapshot enriches matching tasks and marks missing PID as unobserved', () => { + const f = fixture(); f.tasks[0].pid = 12; f.tasks[1].pid = 13; + const resources = collectResources(f.tasks, { platform: 'linux', execFileSync: () => '12 1 32 01:30 S\n' }); + const r = buildInventory(f, { now, resources }); + assert.equal(r.tasks[0].process.state, 'observed'); assert.equal(r.tasks[1].process.state, 'not-observed'); +}); +test('task file adapter reads structured status, labels mtime, skips symlinks and rejects oversized JSON', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coordination-test-')); + try { + fs.mkdirSync(path.join(dir, 'worker')); fs.writeFileSync(path.join(dir, 'worker', 'STATUS.md'), '- State: running\n- Updated: 2026-09-08T06:29:00Z\n'); + fs.symlinkSync(path.join(dir, 'worker'), path.join(dir, 'linked')); + const r = collectTaskFiles(dir); assert.equal(r.tasks.length, 1); assert.equal(r.tasks[0].status, 'running'); + assert.ok(r.tasks[0].statusFileModifiedAt); assert.equal(r.tasks[0].heartbeatAt, '2026-09-08T06:29:00Z'); + fs.writeFileSync(path.join(dir, 'large.json'), ' '.repeat(1024 * 1024 + 1)); + assert.throws(() => readJson(path.join(dir, 'large.json')), /limit/); + assert.equal(collectTaskFiles(path.join(dir, 'missing')).status, 'unavailable'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); +test('CLI JSON end to end, no output file changes and safe errors', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coordination-cli-')); + const cli = path.resolve(__dirname, '../../scripts/coordination-inventory.js'); + try { + const file = path.join(dir,'input.json'); fs.writeFileSync(file, JSON.stringify(fixture())); + const r = spawnSync(process.execPath, [cli, '--manifest', file, '--now', now], { encoding:'utf8' }); + assert.equal(r.status,0,r.stderr); assert.equal(JSON.parse(r.stdout).warnings.length,1); + assert.deepEqual(fs.readdirSync(dir),['input.json']); + const bad = spawnSync(process.execPath,[cli,'--unknown','CANARY_SECRET'],{encoding:'utf8'}); + assert.equal(bad.status,1); assert.ok(!bad.stderr.includes('CANARY_SECRET')); + const help = spawnSync(process.execPath,[cli,'--help'],{encoding:'utf8'}); assert.equal(help.status,0); + } finally { fs.rmSync(dir,{recursive:true,force:true}); } +}); + +test('prototype-named paths and strict calendar dates are safe', () => { + const f = fixture(); f.repositories[0].sources = {}; f.tasks[0].paths = ['toString']; f.tasks[1].paths = ['valueOf']; + assert.equal(run(f).warnings.length, 0); + for (const invalid of ['2026-02-30T00:00:00Z', '2026-09-08T24:00:00Z']) { + f.tasks[0].heartbeatAt = invalid; assert.throws(() => run(f), /Invalid/); + } + f.tasks[0].heartbeatAt = '2026-09-08T06:00:00.1Z'; assert.equal(run(f).tasks[0].heartbeat.state, 'stale'); +}); +test('aggregate comparison budget rejects compact but computationally excessive input', () => { + const f = fixture(); f.repositories[0].sources = {}; + f.tasks = Array.from({length:64}, (_,i) => task(`task${i}`, Array.from({length:128}, (_,j) => `src/${i}/${j}.js`))); + assert.throws(() => run(f), /budget/); +}); +test('CLI discovery composes normalized tasks and reports missing telemetry honestly', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coordination-discovery-')); + try { + fs.mkdirSync(path.join(dir,'worker')); fs.writeFileSync(path.join(dir,'worker','STATUS.md'),'Freeform progress.\n'); + const r = spawnSync(process.execPath,[path.resolve(__dirname,'../../scripts/coordination-inventory.js'),'--coordination',dir,'--now',now],{encoding:'utf8'}); + assert.equal(r.status,0,r.stderr); const report=JSON.parse(r.stdout); + assert.equal(report.tasks[0].status,'unknown'); assert.equal(report.tasks[0].heartbeat.state,'unknown'); + assert.ok(report.tasks[0].statusFileModifiedAt); assert.equal(report.tasks[0].process.state,'unknown'); + } finally { fs.rmSync(dir,{recursive:true,force:true}); } +}); +test('source snippets are bounded before invoking inherited regex extractor', () => { + const f = fixture(); f.repositories[0].sources = { 'a.js': `import ${' '.repeat(32000)}x` }; f.tasks=[]; + assert.throws(() => run(f), /Invalid/); + f.repositories[0].sources = Object.fromEntries(Array.from({length:33},(_,i) => [`${i}.js`, ' '.repeat(1024)])); + assert.throws(() => run(f), /Invalid/); +}); +test('maximum accepted whitespace snippets complete within bounded subprocess timeout', () => { + const code = `const {buildInventory}=require('./scripts/lib/coordination-inventory'); + const source='import '+' '.repeat(1016)+'x'; + const sources=Object.fromEntries(Array.from({length:32},(_,i)=>[i+'.js',source])); + const r=buildInventory({version:1,repositories:[{id:'r',sources}],tasks:[{id:'a',repoId:'r',paths:['0.js']},{id:'b',repoId:'r',paths:['1.js']}]}); + if(r.warnings.length) process.exitCode=1;`; + const r=spawnSync(process.execPath,['-e',code],{cwd:path.resolve(__dirname,'../..'),encoding:'utf8',timeout:2000}); + assert.equal(r.status,0,r.error?.message || r.stderr); +}); +test('bounded import parsing preserves supported JS and TS import forms', () => { + const { buildDependencyGraphFromSources } = require('../../scripts/lib/agent-proximity/graph'); + for (const source of ["import './b'", "import b from './b'", "import { b as c } from './b'", "import * as b from './b'", "import b, { c } from './b'", "import type { B } from './b'", "import {\n b\n} from './b'", "import('./b')"]) { + assert.deepEqual(buildDependencyGraphFromSources({'a.js':source,'b.js':''}).adjacency['a.js'],['b.js']); + } +}); diff --git a/tests/scripts/memory-mcp.test.js b/tests/scripts/memory-mcp.test.js index a234d28a6..9adf37bf5 100644 --- a/tests/scripts/memory-mcp.test.js +++ b/tests/scripts/memory-mcp.test.js @@ -25,32 +25,43 @@ async function test(name, fn) { passed += 1; } catch (error) { console.log(` FAIL ${name}`); - console.log(` ${error.stack || error.message}`); + console.log(` ${error.mcpDiagnostic ? JSON.stringify(error.mcpDiagnostic) : error.stack || error.message}`); failed += 1; } } function createFixture(extraEnv = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-mcp-')); - const projectRoot = path.join(root, 'project'); - const homeDir = path.join(root, 'home'); - fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true }); - fs.mkdirSync(homeDir, { recursive: true }); - return { - root, - projectRoot, - env: Object.fromEntries( - Object.entries({ - ...process.env, - HOME: homeDir, - USERPROFILE: homeDir, - ECC_MEMORY_PROJECT_ROOT: path.join(projectRoot, '.ecc', 'memory'), - ECC_MEMORY_USER_ROOT: path.join(homeDir, '.ecc', 'memory'), - ECC_MEMORY_HARNESS: 'claude', - ...extraEnv, - }).filter(([, value]) => typeof value === 'string') - ), - }; + try { + const projectRoot = path.join(root, 'project'); + const homeDir = path.join(root, 'home'); + fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true }); + fs.mkdirSync(homeDir, { recursive: true }); + return { + root, + projectRoot, + env: Object.fromEntries( + Object.entries({ + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + ECC_MEMORY_PROJECT_ROOT: path.join(projectRoot, '.ecc', 'memory'), + ECC_MEMORY_USER_ROOT: path.join(homeDir, '.ecc', 'memory'), + ECC_MEMORY_HARNESS: 'claude', + ECC_MEMORY_ALLOW_USER_SCOPE: '0', + ...extraEnv, + }).filter(([, value]) => typeof value === 'string') + ), + }; + } catch (error) { + try { fs.rmSync(root, { recursive: true, force: true }); } + catch { + const failure = new Error('MCP fixture cleanup failed', { cause: error }); + failure.mcpCleanupFailure = 'fixture_removal_error'; + throw failure; + } + throw error; + } } function parseTextResult(result) { @@ -60,105 +71,260 @@ function parseTextResult(result) { } async function withClient(fn, options = {}) { - const fixture = createFixture(options.env); - const child = spawn(process.execPath, [options.server || SERVER], { - cwd: fixture.projectRoot, - env: fixture.env, - stdio: ['pipe', 'pipe', 'pipe'], - }); + const started = Date.now(); const pending = new Map(); + const mode = options.env?.ECC_MEMORY_ALLOW_USER_SCOPE === '1' ? 'allow' : 'deny'; + let fixture; + let child; + let phase = 'setup'; let nextId = 1; - let stdout = ''; - let stderr = ''; + let stdout = Buffer.alloc(0); + let stdoutBytes = 0; + let stderrBytes = 0; + let closed = false; + let tearingDown = false; + let transportError; + let primaryError; + let primaryFailed = false; + let failureKind; + let failureElapsedMs; + let teardownStarted; + let failurePhase; + let cleanupFailure; + let killStatus = 'not_attempted'; + let notifyClose; + const closePromise = new Promise(resolve => { notifyClose = resolve; }); + let rejectTransport; + const transportFailure = new Promise((_, reject) => { rejectTransport = reject; }); + // The child may fail before the initialize or callback race is installed. + transportFailure.catch(() => {}); - child.stdout.on('data', chunk => { - stdout += chunk.toString('utf8'); - let newlineIndex = stdout.indexOf('\n'); - while (newlineIndex >= 0) { - const line = stdout.slice(0, newlineIndex); - stdout = stdout.slice(newlineIndex + 1); - if (line.trim()) { - const message = JSON.parse(line); + const bounded = value => Math.min(2147483647, Math.max(0, Math.trunc(value))); + const safeCode = error => [ + 'EPIPE', 'ENOENT', 'EACCES', 'EPERM', 'EINVAL', 'ECONNRESET', + 'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END', 'ERR_ASSERTION', + ].includes(error?.code) ? error.code : null; + const diagnostic = () => ({ + phase: failurePhase || phase, + mode, + reason: failureKind || cleanupFailure || 'assertion_or_callback', + failureElapsedMs: failureElapsedMs ?? null, + teardownElapsedMs: bounded(Date.now() - teardownStarted), + elapsedMs: bounded(Date.now() - started), + stdoutBytes, + stderrBytes, + pendingRequests: pending.size, + childStarted: Boolean(child?.pid), + childClosed: closed, + exitCode: Number.isInteger(child?.exitCode) ? child.exitCode : null, + signal: ['SIGTERM', 'SIGKILL', 'SIGINT'].includes(child?.signalCode) ? child.signalCode : null, + errorCode: safeCode(primaryError), + cleanupFailure: cleanupFailure || null, + killStatus, + }); + function settleAll(error) { + for (const waiter of pending.values()) waiter.reject(error); + pending.clear(); + } + function fail(kind, cause) { + if (tearingDown) { + cleanupFailure ||= kind; + return; + } + if (transportError) return; + transportError = new Error(`MCP test client ${kind}`); + if (safeCode(cause)) transportError.code = safeCode(cause); + failurePhase = phase; + failureKind = kind; + settleAll(transportError); + rejectTransport(transportError); + } + function send(message) { + if (transportError) throw transportError; + try { + child.stdin.write(`${JSON.stringify(message)}\n`, error => { + if (error) fail('stdin_write_error', error); + }); + } catch (error) { + fail('stdin_write_error', error); + throw transportError; + } + } + function request(method, params = {}) { + const id = nextId++; + const promise = new Promise((resolve, reject) => { + if (transportError || tearingDown || closed) { + reject(transportError || new Error('MCP test client is closed')); + return; + } + const timer = setTimeout(() => { + fail('request_timeout'); + }, 5000); + function settle(fn, value) { + clearTimeout(timer); + pending.delete(id); + fn(value); + } + pending.set(id, { + resolve: value => settle(resolve, value), + reject: error => settle(reject, error), + }); + send({ jsonrpc: '2.0', id, method, params }); + }); + // Teardown rejects abandoned requests too, without an unhandled rejection. + promise.catch(() => {}); + return promise; + } + + try { + fixture = createFixture(options.env); + phase = 'spawn'; + child = spawn(process.execPath, [options.server || SERVER], { + cwd: fixture.projectRoot, + env: fixture.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.on('error', error => fail('child_error', error)); + child.on('exit', () => { + if (!tearingDown) fail('child_exit'); + }); + child.once('close', () => { + closed = true; + notifyClose(); + if (!tearingDown) fail('child_close'); + }); + for (const stream of ['stdin', 'stdout', 'stderr']) { + child[stream].on('error', error => fail(`${stream}_error`, error)); + } + child.stdout.on('end', () => { if (!tearingDown) fail('stdout_end'); }); + for (const stream of ['stdin', 'stdout']) { + child[stream].on('close', () => { if (!tearingDown) fail(`${stream}_close`); }); + } + child.stderr.on('data', chunk => { + stderrBytes = bounded(stderrBytes + chunk.length); + }); + child.stdout.on('data', chunk => { + stdoutBytes = bounded(stdoutBytes + chunk.length); + if (transportError || tearingDown) return; + // Decode complete lines, so a UTF-8 character split across chunks survives. + stdout = Buffer.concat([stdout, chunk]); + let newlineIndex; + while ((newlineIndex = stdout.indexOf(10)) >= 0) { + if (newlineIndex > 1024 * 1024) { fail('oversized_frame'); return; } + const line = stdout.subarray(0, newlineIndex).toString('utf8'); + stdout = stdout.subarray(newlineIndex + 1); + if (!line.trim()) continue; + let message; + try { + message = JSON.parse(line); + if (!message || message.jsonrpc !== '2.0' || !Number.isInteger(message.id) + || (Object.hasOwn(message, 'result') === Object.hasOwn(message, 'error')) + || (Object.hasOwn(message, 'error') && (!message.error + || !Number.isInteger(message.error.code) || typeof message.error.message !== 'string'))) { + fail('invalid_frame'); + return; + } + } catch { + fail('malformed_frame'); + return; + } const waiter = pending.get(message.id); if (waiter) { - pending.delete(message.id); if (message.error) { + // Existing authorization/protocol assertions inspect this RPC error. + // The test logger emits only mcpDiagnostic when it escapes the helper. waiter.reject(new Error(`${message.error.code}: ${message.error.message}`)); } else { waiter.resolve(message.result); } } } - newlineIndex = stdout.indexOf('\n'); + if (stdout.length > 1024 * 1024) fail('oversized_frame'); + }); + + phase = 'initialize'; + const initialized = await request('initialize', { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'ecc-memory-test', version: '1.0.0' }, + }); + phase = 'protocol'; + assert.strictEqual(initialized.protocolVersion, '2025-11-25'); + phase = 'notification'; + send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }); + const client = { + listTools: () => request('tools/list'), + listToolsRaw: params => request('tools/list', params), + callTool: ({ name, arguments: toolArguments }) => request( + 'tools/call', + { name, arguments: toolArguments } + ), + callToolRaw: params => request('tools/call', params), + }; + phase = 'callback'; + await Promise.race([Promise.resolve().then(() => fn(client, fixture)), transportFailure]); + if (transportError) throw transportError; + assert.strictEqual(pending.size, 0, 'MCP callback must await its requests'); + } catch (error) { + primaryError = error; + primaryFailed = true; + failurePhase ||= phase; + failureElapsedMs = bounded(Date.now() - started); + if (error?.mcpCleanupFailure === 'fixture_removal_error') { + cleanupFailure ||= 'fixture_removal_error'; } - }); - child.stderr.on('data', chunk => { - stderr += chunk.toString('utf8'); - }); - - function send(message) { - child.stdin.write(`${JSON.stringify(message)}\n`); - } - - function request(method, params = {}) { - const id = nextId; - nextId += 1; - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - pending.delete(id); - reject(new Error(`Timed out waiting for ${method}. stderr: ${stderr}`)); - }, 5000); - pending.set(id, { - resolve: value => { - clearTimeout(timeout); - resolve(value); - }, - reject: error => { - clearTimeout(timeout); - reject(error); - }, - }); - send({ jsonrpc: '2.0', id, method, params }); - }); - } - - const initialized = await request('initialize', { - protocolVersion: '2025-11-25', - capabilities: {}, - clientInfo: { name: 'ecc-memory-test', version: '1.0.0' }, - }); - assert.strictEqual(initialized.protocolVersion, '2025-11-25'); - send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }); - - const client = { - listTools: () => request('tools/list'), - listToolsRaw: params => request('tools/list', params), - callTool: ({ name, arguments: toolArguments }) => request( - 'tools/call', - { name, arguments: toolArguments } - ), - callToolRaw: params => request('tools/call', params), - }; - - try { - await fn(client, fixture); } finally { - child.stdin.end(); - await new Promise(resolve => { - if (child.exitCode !== null) { - resolve(); - return; + tearingDown = true; + teardownStarted = Date.now(); + phase = 'teardown'; + settleAll(new Error('MCP test client is closing')); + stdout = Buffer.alloc(0); + if (child && !closed) { + // Keep the original total 2000 ms budget. Reserve its latter half for + // direct-child termination and stdio close, including on Windows. + let killTimer; + let deadlineTimer; + function terminate() { + try { killStatus = child.kill() ? 'requested' : 'not_sent'; } + catch { killStatus = 'error'; } } - const timeout = setTimeout(() => { - child.kill(); - resolve(); - }, 2000); - child.once('exit', () => { - clearTimeout(timeout); - resolve(); + const deadline = new Promise(resolve => { + deadlineTimer = setTimeout(resolve, 2000); + killTimer = setTimeout(terminate, 1000); }); - }); - fs.rmSync(fixture.root, { recursive: true, force: true }); + try { + try { child.stdin.end(); } + catch { + cleanupFailure ||= 'stdin_end_error'; + clearTimeout(killTimer); + terminate(); + } + await Promise.race([closePromise, deadline]); + } finally { + clearTimeout(killTimer); + clearTimeout(deadlineTimer); + } + if (!closed) cleanupFailure ||= 'child_close_timeout'; + } + if (fixture && (!child || closed)) { + try { fs.rmSync(fixture.root, { recursive: true, force: true }); } + catch { cleanupFailure ||= 'fixture_removal_error'; } + } + } + if (primaryFailed || cleanupFailure) { + if (!primaryFailed) primaryError = new Error('MCP test client cleanup failed'); + // Keep the primary assertion/RPC/callback error; cleanup must not replace it. + // A wrapper retains non-extensible or non-Error thrown values as its cause. + if (!primaryError || typeof primaryError !== 'object' || !Object.isExtensible(primaryError) + || Object.getOwnPropertyDescriptor(primaryError, 'mcpDiagnostic')?.configurable === false + || Object.getOwnPropertyDescriptor(primaryError, 'mcpCleanupFailure')?.configurable === false) { + primaryError = new Error('MCP test client failed', { cause: primaryError }); + } + Object.defineProperty(primaryError, 'mcpDiagnostic', { value: diagnostic(), configurable: true }); + if (cleanupFailure) { + Object.defineProperty(primaryError, 'mcpCleanupFailure', { value: cleanupFailure, configurable: true }); + } + throw primaryError; } }