mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-13 05:07:53 +02:00
Distinguish declared goals, open sessions and overlap risk in coordination inventory (#3028)
* feat: add read-only coordination inventory and overlap evaluation * test: make coordination process fixtures platform explicit * test: report bounded Stop wrapper failure diagnostics * test: clean up failed memory MCP sessions deterministically * fix: update js-yaml to patched 4.3.2 * feat(coordination): distinguish declared goals from open sessions
This commit is contained in:
@@ -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.
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }] }));
|
||||
});
|
||||
@@ -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']);
|
||||
}
|
||||
});
|
||||
+269
-103
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user