Files
msd-core/tests/external-job.test.cjs
Tom Boucher 5ae4ea4c84 feat(#1105): add external-job capability (SLURM scheduler-adapter producer half) (#1998)
* feat(#1105): add external-job capability (SLURM scheduler-adapter producer half)

The async external-job consumer half (#1165) shipped long ago: the core loop
reads .planning/async-jobs/<job>.json manifests and treats a non-terminal one
as the legal external_job_waiting half-state. The PRODUCER half (#1164) was
the remaining unimplemented piece of #1105.

This adds the producer as a default-off capability:

- capabilities/external-job/ — capability.json (execute:wave:post -> executor,
  plan:post -> planner contributions, external_job.* config keys, default-off)
  + fragments teaching runtime-budget classification and externalization.
- src/external-job.cts -> gsd-core/bin/lib/external-job.cjs — pure producer
  module: SLURM state -> manifest-status map (no guessing), manifest
  build/validate (versioned stability contract), sbatch/squeue/sacct parsers,
  and a fail-closed manifest writer (refuses a second non-terminal job for a
  plan_id already in flight; refuses to clobber a malformed manifest). fs/clock
  seams for deterministic tests.
- scripts/slurm-adapter.cjs — operator CLI (submit/poll/show) wrapping bounded
  sbatch/squeue/sacct subprocesses; surfaces manifest commands for confirmation
  and never auto-runs them (trust boundary).
- tests/external-job.test.cjs — 23 behavioral + fast-check property tests.
- docs/reference/long-running-operations.md + docs/how-to/async-external-jobs.md.
- CONTEXT.md glossary entry for the External-job Capability.
- Regenerated capability-registry.cjs; pruned the now-stale test-file-count
  allowlist entry (external-job is at the 2-file cap).

* chore(#1105): backfill PR number in changeset

* fix(#1105): sync capability artifacts + update registry shape-pin tests

gsd-test caught that adding the external-job capability requires its
dependent artifacts regenerated and its registry-shape drift absorbed:

- sync-manifest-versions: stamp 1.7.0-rc.2 into capability.json (was 1.0.0).
- gen-capability-matrix --write: regenerate docs/reference/capability-matrix.md.
- gen-inventory-manifest --write: regenerate docs/INVENTORY-MANIFEST.json.
- check-gap-analysis-plan-post-e2e: plan:post now has 1 contribution
  (external-job planner fragment) instead of 0.
- execute-wave-post-gate-pipeline-e2e: execute:wave:post now has 2
  contributions (mempalace + external-job) instead of 1.

* fix(#1105): regenerate capability-registry after version stamp

sync-manifest-versions re-stamped external-job/capability.json from
1.0.0 to 1.7.0-rc.2 after the last registry regeneration, leaving the
committed capability-registry.cjs stale (CI gen-capability-registry
--check failed). gsd-test masked this because its setup runs the full
'npm run build' (which regenerates the registry); CI's 'npm test'
pretest only runs build:lib.
2026-07-03 19:37:38 -04:00

312 lines
13 KiB
JavaScript

'use strict';
// Producer-half of the async external-job contract (#1164 / #1105).
// The CORE consumer half (external_job_waiting) is covered by
// external-job-waiting.test.cjs. These tests assert the scheduler-adapter
// Capability's pure module: SLURM state mapping, manifest build/validate,
// sbatch/squeue/sacct parsers, and the fail-closed manifest writer that
// mirrors the consumer's duplicate-execution guard
// (docs/reference/planning-artifacts.md).
const { test } = require('node:test');
const assert = require('node:assert');
const path = require('node:path');
const fc = require('fast-check');
const m = require('../gsd-core/bin/lib/external-job.cjs');
const {
MANIFEST_VERSION,
MANIFEST_STATUS,
NON_TERMINAL_STATUSES,
TERMINAL_FAILURE_STATUSES,
mapSlurmState,
buildManifest,
validateManifest,
parseSbatchParsable,
parseSqueueLine,
parseSacctRow,
writeManifest,
manifestPath,
} = m;
// ─── Closed status enum (Hyrum's Law: stability contract) ─────────────────────
test('MANIFEST_STATUS is the closed scheduler-agnostic enum from the contract', () => {
assert.deepStrictEqual([...MANIFEST_STATUS].sort(), [
'cancelled',
'completed-unverified',
'failed',
'running',
'submitted',
'timeout',
]);
assert.strictEqual(MANIFEST_VERSION, '1.0');
});
test('NON_TERMINAL / TERMINAL_FAILURE partition the enum without overlap', () => {
for (const s of MANIFEST_STATUS) {
const inNon = NON_TERMINAL_STATUSES.includes(s);
const inTerm = TERMINAL_FAILURE_STATUSES.includes(s);
// completed-unverified is neither non-terminal nor a failure — its own bucket.
if (s === 'completed-unverified') {
assert.ok(!inNon && !inTerm, 'completed-unverified is its own bucket');
} else {
assert.ok(inNon !== inTerm, `${s} must sit in exactly one partition`);
}
}
});
// ─── SLURM state mapping ──────────────────────────────────────────────────────
test('mapSlurmState maps every documented SLURM state to the closed enum', () => {
const cases = {
PENDING: 'submitted',
CONFIGURING: 'submitted',
RUNNING: 'running',
COMPLETED: 'completed-unverified',
COMPLETING: 'running',
FAILED: 'failed',
CANCELLED: 'cancelled',
TIMEOUT: 'timeout',
OUT_OF_MEMORY: 'failed',
BOOT_FAIL: 'failed',
NODE_FAIL: 'failed',
PREEMPTED: 'failed',
};
for (const [slurm, expected] of Object.entries(cases)) {
assert.strictEqual(mapSlurmState(slurm), expected, `${slurm} -> ${expected}`);
}
});
test('mapSlurmState is case-insensitive and trims whitespace', () => {
assert.strictEqual(mapSlurmState('running'), 'running');
assert.strictEqual(mapSlurmState(' PENDING '), 'submitted');
assert.strictEqual(mapSlurmState('Cancelled'), 'cancelled');
});
test('mapSlurmState returns null for unknown states (no guessing)', () => {
// Boundary: unknown must not collapse to a terminal failure silently.
assert.strictEqual(mapSlurmState('NO_SUCH_STATE'), null);
assert.strictEqual(mapSlurmState(''), null);
assert.strictEqual(mapSlurmState('COMPLETED2'), null);
});
// ─── Manifest build ───────────────────────────────────────────────────────────
function baseInput() {
return {
plan_id: '3.1',
phase: '3',
job_id: '12345',
backend: 'slurm',
submit_command: 'sbatch --parsable ./run.sh',
status: 'submitted',
expected_artifacts: ['Artifacts/jobs/12345/result.h5'],
verification_command: 'python -m verify.py 12345',
resume_command: '/gsd:execute-phase 3',
};
}
test('buildManifest stamps version and submitted_at via the clock seam', () => {
const clock = { nowIso: () => '2020-06-15T12:00:00.000Z' };
const out = buildManifest(baseInput(), { clock });
assert.strictEqual(out.version, '1.0');
assert.strictEqual(out.submitted_at, '2020-06-15T12:00:00.000Z');
assert.strictEqual(out.terminal_details, null, 'non-terminal -> null terminal_details');
assert.strictEqual(out.plan_id, '3.1');
});
test('buildManifest rejects missing required fields', () => {
for (const key of ['plan_id', 'phase', 'job_id', 'backend', 'submit_command', 'status', 'expected_artifacts', 'verification_command', 'resume_command']) {
const bad = baseInput();
delete bad[key];
assert.throws(() => buildManifest(bad), { message: new RegExp(key) }, `missing ${key} must throw`);
}
});
test('buildManifest rejects an out-of-enum status', () => {
const bad = baseInput();
bad.status = 'done';
assert.throws(() => buildManifest(bad), /status/i);
});
test('buildManifest sets terminal_details when status is a terminal failure', () => {
const clock = { nowIso: () => '2020-06-15T12:00:00.000Z' };
for (const status of TERMINAL_FAILURE_STATUSES) {
const input = { ...baseInput(), status, terminal_details: { reason: 'oom', exit_code: 137 } };
const out = buildManifest(input, { clock });
assert.deepStrictEqual(out.terminal_details, { reason: 'oom', exit_code: 137 }, `${status} carries terminal_details`);
}
});
// ─── validateManifest (producer-side mirror of the trust boundary) ────────────
test('validateManifest accepts a well-formed manifest', () => {
const clock = { nowIso: () => '2020-06-15T12:00:00.000Z' };
const res = validateManifest(buildManifest(baseInput(), { clock }));
assert.strictEqual(res.ok, true);
});
test('validateManifest rejects bad version, status, and missing plan_id', () => {
const good = buildManifest(baseInput(), { clock: { nowIso: () => '2020-06-15T12:00:00.000Z' } });
const badVersion = { ...good, version: '9.9' };
assert.strictEqual(validateManifest(badVersion).ok, false);
const badStatus = { ...good, status: 'finished' };
assert.strictEqual(validateManifest(badStatus).ok, false);
const noPlan = { ...good };
delete noPlan.plan_id;
assert.strictEqual(validateManifest(noPlan).ok, false);
});
// ─── Parsers ──────────────────────────────────────────────────────────────────
test('parseSbatchParsable parses a bare number and a number;cluster form', () => {
assert.deepStrictEqual(parseSbatchParsable('12345'), { ok: true, job_id: '12345' });
assert.deepStrictEqual(parseSbatchParsable('12345;mycluster\n'), { ok: true, job_id: '12345' });
});
test('parseSbatchParsable fails closed on empty or non-numeric output', () => {
assert.strictEqual(parseSbatchParsable('').ok, false);
assert.strictEqual(parseSbatchParsable('Submitted batch job 12345').ok, false, 'non-parsable prose rejected');
assert.strictEqual(parseSbatchParsable('abc;cluster').ok, false);
});
test('parseSqueueLine parses "<jobid> <state>" and returns null for malformed', () => {
assert.deepStrictEqual(parseSqueueLine('12345 RUNNING'), { job_id: '12345', state: 'RUNNING' });
assert.strictEqual(parseSqueueLine('header'), null);
assert.strictEqual(parseSqueueLine(''), null);
});
test('parseSacctRow parses [jobid, state] columns', () => {
assert.deepStrictEqual(parseSacctRow(['12345', 'COMPLETED']), { job_id: '12345', state: 'COMPLETED' });
assert.strictEqual(parseSacctRow(['x']), null);
assert.strictEqual(parseSacctRow([], ), null);
});
// ─── writeManifest (fail-closed duplicate guard + fs injection) ────────────────
function memFs(files = {}) {
const store = new Map(Object.entries(files));
return {
mkdirSync: () => undefined,
readdirSync: (d) => {
const set = store.get(d);
return Array.isArray(set) ? set : [];
},
readFileSync: (p) => {
if (!store.has(p)) { const e = new Error('enoent'); e.code = 'ENOENT'; throw e; }
return store.get(p);
},
writeFileSync: (p, c) => { store.set(p, c); },
existsSync: (p) => store.has(p),
};
}
test('manifestPath projects to .planning/async-jobs/<job>.json', () => {
assert.strictEqual(
manifestPath('.planning', '12345'),
path.join('.planning', 'async-jobs', '12345.json'),
);
});
test('writeManifest writes a new manifest and returns its path', () => {
const fs = memFs({ [path.join('.planning', 'async-jobs')]: [] });
const clock = { nowIso: () => '2020-06-15T12:00:00.000Z' };
const manifest = buildManifest(baseInput(), { clock });
const res = writeManifest(manifest, '.planning', { fs, clock });
assert.strictEqual(res.ok, true);
assert.ok(res.path.endsWith(path.join('async-jobs', '12345.json')));
const written = JSON.parse(fs.readFileSync(res.path));
assert.strictEqual(written.plan_id, '3.1');
});
test('writeManifest allows updating the SAME job_id (status progression)', () => {
const dir = path.join('.planning', 'async-jobs');
const existingPath = path.join(dir, '12345.json');
const clock = { nowIso: () => '2020-06-15T12:00:00.000Z' };
const submitted = buildManifest(baseInput(), { clock });
const existing = { ...submitted };
const fs = memFs({ [dir]: ['12345.json'], [existingPath]: JSON.stringify(existing) });
const running = buildManifest({ ...baseInput(), status: 'running' }, { clock });
const res = writeManifest(running, '.planning', { fs, clock });
assert.strictEqual(res.ok, true, 'same job_id progression must be allowed');
});
test('writeManifest FAILS CLOSED when a different non-terminal job exists for the same plan_id', () => {
// Duplicate-execution guard: a second dispatch for the same plan would
// duplicate the external job. Mirror of planning-artifacts.md fail-closed.
const dir = path.join('.planning', 'async-jobs');
const otherPath = path.join(dir, '99999.json');
const clock = { nowIso: () => '2020-06-15T12:00:00.000Z' };
const other = buildManifest({ ...baseInput(), job_id: '99999' }, { clock });
const fs = memFs({ [dir]: ['99999.json'], [otherPath]: JSON.stringify(other) });
const second = buildManifest({ ...baseInput(), job_id: '12345' }, { clock });
const res = writeManifest(second, '.planning', { fs, clock });
assert.strictEqual(res.ok, false);
assert.strictEqual(res.kind, 'duplicate_plan_id');
});
test('writeManifest allows a NEW job once the prior plan_id job is terminal', () => {
const dir = path.join('.planning', 'async-jobs');
const otherPath = path.join(dir, '99999.json');
const clock = { nowIso: () => '2020-06-15T12:00:00.000Z' };
const dead = buildManifest({ ...baseInput(), job_id: '99999', status: 'failed', terminal_details: { code: 1 } }, { clock });
const fs = memFs({ [dir]: ['99999.json'], [otherPath]: JSON.stringify(dead) });
const next = buildManifest({ ...baseInput(), job_id: '12345' }, { clock });
const res = writeManifest(next, '.planning', { fs, clock });
assert.strictEqual(res.ok, true, 'terminal prior job must not block a new dispatch');
});
test('writeManifest fails closed on a malformed existing manifest', () => {
const dir = path.join('.planning', 'async-jobs');
const brokenPath = path.join(dir, '12345.json');
const fs = memFs({ [dir]: ['12345.json'], [brokenPath]: '{not json' });
const clock = { nowIso: () => '2020-06-15T12:00:00.000Z' };
const manifest = buildManifest(baseInput(), { clock });
const res = writeManifest(manifest, '.planning', { fs, clock });
assert.strictEqual(res.ok, false);
assert.strictEqual(res.kind, 'malformed_existing');
});
// ─── Property-based (CLAUDE.md: parsers/contracts need a fast-check test) ─────
test('property: mapSlurmState is total and idempotent over the known alphabet', () => {
fc.assert(
fc.property(fc.constantFrom(
'PENDING', 'CONFIGURING', 'RUNNING', 'COMPLETING', 'COMPLETED',
'FAILED', 'CANCELLED', 'TIMEOUT', 'OUT_OF_MEMORY', 'BOOT_FAIL', 'NODE_FAIL', 'PREEMPTED',
), (state) => {
const a = mapSlurmState(state);
const b = mapSlurmState(state);
return a !== null && a === b && MANIFEST_STATUS.includes(a);
}),
{ numRuns: 200 },
);
});
test('property: buildManifest -> validateManifest round-trips for valid generated input', () => {
fc.assert(
fc.property(
fc.record({
plan_id: fc.stringMatching(/^[0-9]+\.[0-9]+$/),
phase: fc.stringMatching(/^[0-9]+$/),
job_id: fc.stringMatching(/^[0-9]{1,8}$/),
backend: fc.constantFrom('slurm'),
submit_command: fc.constantFrom('sbatch --parsable ./run.sh'),
status: fc.constantFrom(...MANIFEST_STATUS),
expected_artifacts: fc.array(fc.constantFrom('Artifacts/jobs/x/out.h5'), { minLength: 1 }),
verification_command: fc.constantFrom('python -m verify.py'),
resume_command: fc.constantFrom('/gsd:execute-phase 3'),
terminal_details: fc.oneof(fc.constant(null), fc.record({ code: fc.integer() })),
}),
(input) => {
const clock = { nowIso: () => '2020-06-15T12:00:00.000Z' };
const td = input.status === 'completed-unverified' ? null : input.terminal_details;
const built = buildManifest({ ...input, terminal_details: td }, { clock });
return validateManifest(built).ok === true;
},
),
{ numRuns: 100 },
);
});