Files
msd-core/scripts/slurm-adapter.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

196 lines
7.6 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* slurm-adapter.cjs — SLURM scheduler-adapter helper for the external-job
* capability (#1164 / #1105).
*
* Thin CLI: runs bounded sbatch / squeue / sacct subprocesses and delegates
* all parsing, manifest build/validate, and fail-closed writing to the pure
* module (gsd-core/bin/lib/external-job.cjs). The pure module is fully unit-
* tested; this script is the operator surface that needs a real cluster.
*
* Subcommands:
* submit --plan <plan_id> --phase <phase> --expected <path>[,<path>] \
* --verify <cmd> --resume <cmd> -- <sbatch...>
* poll --job <job_id> [--plan <plan_id>]
* show --job <job_id> (surface manifest status + commands; no auto-run)
*
* Trust boundary: this script never auto-runs verification_command or
* resume_command from a manifest (planning-artifacts.md). `show` prints them
* for explicit operator confirmation.
*/
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const m = require('../gsd-core/bin/lib/external-job.cjs');
const SUBMIT_TIMEOUT_MS = Number(process.env.GSD_SLURM_SUBMIT_TIMEOUT_MS || 30000);
const POLL_TIMEOUT_MS = Number(process.env.GSD_SLURM_POLL_TIMEOUT_MS || 15000);
function usage() {
return [
'usage: slurm-adapter.cjs <submit|poll|show> ...',
' submit --plan <id> --phase <n> --expected <p1,p2> --verify <cmd> --resume <cmd> -- sbatch --parsable ...',
' poll --job <job_id> [--plan <plan_id>]',
' show --job <job_id>',
].join('\n');
}
function parseFlags(argv) {
const out = {};
const rest = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--') { out['--'] = argv.slice(i + 1); break; }
if (a.startsWith('--')) {
const v = argv[i + 1];
out[a.slice(2)] = v;
i++;
} else {
rest.push(a);
}
}
out._ = rest;
return out;
}
function findPlanningDir(start) {
let dir = path.resolve(start || process.cwd());
for (let i = 0; i < 10; i++) {
if (fs.existsSync(path.join(dir, '.planning'))) return path.join(dir, '.planning');
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new ExitError(1, 'could not locate a .planning directory (walked up 10 levels)');
}
function cmdSubmit(flags) {
const plan = flags.plan;
const phase = flags.phase;
const sbatchCmd = flags['--'];
if (!plan || phase === undefined || !Array.isArray(sbatchCmd) || sbatchCmd.length === 0) {
throw new ExitError(1, 'submit requires --plan, --phase, and an sbatch command after --\n' + usage());
}
const expected = (flags.expected || '').split(',').map((s) => s.trim()).filter(Boolean);
if (expected.length === 0) throw new ExitError(1, 'submit requires --expected (comma-separated artifact paths)');
const verify = flags.verify;
const resume = flags.resume || ('/gsd:execute-phase ' + phase);
if (!verify) throw new ExitError(1, 'submit requires --verify (the command that verifies job output)');
let stdout;
try {
stdout = execFileSync(sbatchCmd[0], sbatchCmd.slice(1), {
encoding: 'utf8',
timeout: SUBMIT_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
});
} catch (e) {
throw new ExitError(1, 'sbatch failed: ' + (e.message || String(e)));
}
const parsed = m.parseSbatchParsable(stdout);
if (!parsed.ok) {
throw new ExitError(1, 'could not parse sbatch --parsable output (kind=' + parsed.kind + '): ' + parsed.raw);
}
const manifest = m.buildManifest({
plan_id: plan,
phase,
job_id: parsed.job_id,
backend: 'slurm',
submit_command: sbatchCmd.join(' '),
status: 'submitted',
expected_artifacts: expected,
verification_command: verify,
resume_command: resume,
});
const planningDir = findPlanningDir();
const res = m.writeManifest(manifest, planningDir);
if (!res.ok) {
throw new ExitError(1, 'writeManifest refused (' + res.kind + '): ' + res.message);
}
process.stdout.write('submitted job ' + parsed.job_id + ' for plan ' + plan + '\n');
process.stdout.write('manifest: ' + res.path + '\n');
process.stdout.write('state: external_job_waiting (SUMMARY deferred)\n');
}
function cmdPoll(flags) {
const jobId = flags.job;
if (!jobId) throw new ExitError(1, 'poll requires --job <job_id>\n' + usage());
const planningDir = findPlanningDir();
const manifestFile = m.manifestPath(planningDir, jobId);
if (!fs.existsSync(manifestFile)) {
throw new ExitError(1, 'no manifest for job ' + jobId + ' at ' + manifestFile);
}
const existing = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
let rawState = null;
try {
const out = execFileSync('squeue', ['-h', '-j', jobId, '-o', '%i %T'], {
encoding: 'utf8', timeout: POLL_TIMEOUT_MS, maxBuffer: 1024 * 1024,
}).trim();
const line = out.split('\n')[0];
const parsed = m.parseSqueueLine(line || '');
if (parsed) rawState = parsed.state;
} catch (_e) { /* squeue empty/failed — fall back to sacct */ }
if (!rawState) {
try {
const out = execFileSync('sacct', ['-X', '-P', '-j', jobId, '-o', 'JobID,State'], {
encoding: 'utf8', timeout: POLL_TIMEOUT_MS, maxBuffer: 1024 * 1024,
}).trim();
for (const line of out.split('\n').slice(1)) {
const parsed = m.parseSacctRow(line.split('|'));
if (parsed) { rawState = parsed.state; break; }
}
} catch (e) {
throw new ExitError(1, 'both squeue and sacct failed: ' + (e.message || String(e)));
}
}
const mapped = m.mapSlurmState(rawState || '');
if (!mapped) throw new ExitError(1, 'unmapped SLURM state "' + rawState + '" — not guessing; inspect manually');
const updated = m.buildManifest(
Object.assign({}, existing, { status: mapped, terminal_details: existing.terminal_details || null }),
{ clock: { nowIso: () => existing.submitted_at } },
);
const res = m.writeManifest(updated, planningDir);
if (!res.ok) throw new ExitError(1, 'writeManifest refused (' + res.kind + '): ' + res.message);
process.stdout.write(JSON.stringify({ job_id: jobId, slurm_state: rawState, manifest_status: mapped, path: res.path }) + '\n');
}
function cmdShow(flags) {
const jobId = flags.job;
if (!jobId) throw new ExitError(1, 'show requires --job <job_id>\n' + usage());
const planningDir = findPlanningDir();
const manifestFile = m.manifestPath(planningDir, jobId);
if (!fs.existsSync(manifestFile)) {
throw new ExitError(1, 'no manifest for job ' + jobId + ' at ' + manifestFile);
}
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
process.stdout.write('job ' + manifest.job_id + ' (plan ' + manifest.plan_id + ', backend ' + manifest.backend + ')\n');
process.stdout.write('status: ' + manifest.status + '\n');
if (manifest.terminal_details) {
process.stdout.write('terminal_details: ' + JSON.stringify(manifest.terminal_details) + '\n');
}
// Trust boundary: surface commands for confirmation, never auto-run.
process.stdout.write('\nManifest commands (UNTRUSTED — confirm before running):\n');
process.stdout.write(' submit_command: ' + manifest.submit_command + '\n');
process.stdout.write(' verification_command: ' + manifest.verification_command + '\n');
process.stdout.write(' resume_command: ' + manifest.resume_command + '\n');
}
function main() {
const [, , sub, ...rest] = process.argv;
const flags = parseFlags(rest);
if (sub === 'submit') cmdSubmit(flags);
else if (sub === 'poll') cmdPoll(flags);
else if (sub === 'show') cmdShow(flags);
else throw new ExitError(1, usage());
return 0;
}
runMain(main);