@ruvector/ruvllm-gpu-loop 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GPU-LOOP.md +238 -0
- package/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +204 -2
- package/examples/gpu-loop.example.json +26 -0
- package/package.json +69 -4
- package/src/adapters-cli.mjs +23 -0
- package/src/adapters.mjs +91 -0
- package/src/cli.mjs +30 -0
- package/src/engine.mjs +255 -0
- package/src/fixture.mjs +38 -0
- package/src/gpu-host.mjs +97 -0
- package/src/gpu-loop-cli.mjs +67 -0
- package/src/gpu-loop.mjs +184 -0
- package/src/gpu-spool-adapter.mjs +80 -0
- package/src/vast-host-adapter.mjs +400 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// Vast.ai host adapter for the scheduled GPU loop (ADR-015, ADR-017). Trusted
|
|
2
|
+
// operator code: it resolves the job's secretRef from an owner-only host config,
|
|
3
|
+
// rents exactly the configured offer, never reuses a shared instance, and only
|
|
4
|
+
// reports a verified completion after re-scoring the retrieved raw outputs here.
|
|
5
|
+
// No effect happens at import; `node vast-host-adapter.mjs guard ID REF` is the
|
|
6
|
+
// independent destroy timer armed for every rental.
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { constants, createWriteStream } from 'node:fs';
|
|
10
|
+
import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
12
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
export const HOST_CONFIG_ENV = 'RUVLLM_VAST_HOST_CONFIG';
|
|
16
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
17
|
+
const INSPECT_BUDGET_MS = 24000;
|
|
18
|
+
// Evidence comes from a rented host and is untrusted until re-scored here.
|
|
19
|
+
const DEFAULT_MAX_EVIDENCE_BYTES = 2 * 1024 ** 3;
|
|
20
|
+
const DEFAULT_MAX_EVIDENCE_ENTRIES = 20000;
|
|
21
|
+
const EVIDENCE_ROOTS = new Set(['out', 'run']);
|
|
22
|
+
const fail = code => { const error = new Error(code); error.code = code; throw error; };
|
|
23
|
+
const need = (ok, code) => { if (!ok) fail(code); };
|
|
24
|
+
const abs = value => typeof value === 'string' && isAbsolute(value) && resolve(value) === value;
|
|
25
|
+
const ceilMicro = usd => Math.ceil(usd * 1e6) / 1e6;
|
|
26
|
+
|
|
27
|
+
async function privateJson(path) {
|
|
28
|
+
need(abs(path), 'VAST_HOST_CONFIG_PATH');
|
|
29
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
30
|
+
try {
|
|
31
|
+
const info = await handle.stat();
|
|
32
|
+
need(info.isFile() && (info.mode & 0o077) === 0 && info.uid === process.getuid() && info.size <= 65536, 'VAST_HOST_CONFIG_UNSAFE');
|
|
33
|
+
const bytes = await handle.readFile();
|
|
34
|
+
return { value: JSON.parse(bytes), sha256: createHash('sha256').update(bytes).digest('hex') };
|
|
35
|
+
} finally { await handle.close(); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function readHostConfig(path = process.env[HOST_CONFIG_ENV]) {
|
|
39
|
+
const { value: config, sha256 } = await privateJson(path);
|
|
40
|
+
need(config?.schema === 'ruvllm-vast-host/v1', 'VAST_HOST_CONFIG_SCHEMA');
|
|
41
|
+
need(/^https:\/\/[a-z0-9.-]+$/.test(config.apiBase), 'VAST_HOST_CONFIG_API');
|
|
42
|
+
need(/^[a-z0-9./_-]+@sha256:[a-f0-9]{64}$/.test(config.image), 'VAST_HOST_CONFIG_IMAGE');
|
|
43
|
+
need(/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/.test(config.repoUrl) && /^[a-f0-9]{40}$/.test(config.commit), 'VAST_HOST_CONFIG_SOURCE');
|
|
44
|
+
need(/^v[0-9]+$/.test(config.bundle), 'VAST_HOST_CONFIG_BUNDLE');
|
|
45
|
+
need(Number.isInteger(config.diskGb) && config.diskGb >= 20 && config.diskGb <= 200, 'VAST_HOST_CONFIG_DISK');
|
|
46
|
+
need(Number.isInteger(config.armDeadlineSeconds) && config.armDeadlineSeconds >= 60 && config.armDeadlineSeconds <= 43200, 'VAST_HOST_CONFIG_DEADLINE');
|
|
47
|
+
need(Number.isInteger(config.guardGraceSeconds) && config.guardGraceSeconds >= 60 && config.guardGraceSeconds <= 3600, 'VAST_HOST_CONFIG_GUARD');
|
|
48
|
+
need(['resultsRoot', 'sshKey', 'repoRoot', 'python', 'node'].every(key => abs(config[key])), 'VAST_HOST_CONFIG_PATHS');
|
|
49
|
+
need(config.adapterDir === undefined || (/^[a-z0-9][a-z0-9._-]{0,63}$/.test(config.adapterDir) && !config.adapterDir.includes('..')), 'VAST_HOST_CONFIG_ADAPTER');
|
|
50
|
+
need(config.trainPayload === undefined || /^experiments\/model-improvement\/training\/[a-z0-9][a-z0-9._-]{0,63}$/.test(config.trainPayload), 'VAST_HOST_CONFIG_PAYLOAD');
|
|
51
|
+
need(config.secrets && typeof config.secrets === 'object' && Object.values(config.secrets).every(entry => abs(entry?.file)), 'VAST_HOST_CONFIG_SECRETS');
|
|
52
|
+
need(config.maxEvidenceBytes === undefined || (Number.isSafeInteger(config.maxEvidenceBytes) && config.maxEvidenceBytes >= 1 << 20 && config.maxEvidenceBytes <= 64 * 1024 ** 3), 'VAST_HOST_CONFIG_EVIDENCE');
|
|
53
|
+
need(config.maxEvidenceEntries === undefined || (Number.isInteger(config.maxEvidenceEntries) && config.maxEvidenceEntries >= 1 && config.maxEvidenceEntries <= 1000000), 'VAST_HOST_CONFIG_EVIDENCE');
|
|
54
|
+
// SSH host keys: pinned in an operator-managed known_hosts file, or trust on
|
|
55
|
+
// first use only when explicitly enabled. Checked before anything is rented.
|
|
56
|
+
need(config.sshTrustOnFirstUse === undefined || typeof config.sshTrustOnFirstUse === 'boolean', 'VAST_HOST_CONFIG_SSH_TRUST');
|
|
57
|
+
need(config.sshKnownHosts === undefined || abs(config.sshKnownHosts), 'VAST_HOST_CONFIG_SSH_TRUST');
|
|
58
|
+
need(config.sshKnownHosts !== undefined || config.sshTrustOnFirstUse === true, 'VAST_HOST_CONFIG_SSH_TRUST');
|
|
59
|
+
if (config.sshKnownHosts !== undefined) {
|
|
60
|
+
const info = await stat(config.sshKnownHosts);
|
|
61
|
+
need(info.isFile() && (info.mode & 0o022) === 0 && info.uid === process.getuid() && info.size > 0, 'VAST_SSH_KNOWN_HOSTS_UNSAFE');
|
|
62
|
+
}
|
|
63
|
+
return Object.freeze({ ...config, sha256 });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function apiKey(config, ref) {
|
|
67
|
+
const entry = Object.hasOwn(config.secrets, ref) ? config.secrets[ref] : null;
|
|
68
|
+
need(entry, 'VAST_SECRET_REF_UNKNOWN');
|
|
69
|
+
const { mode, uid } = await stat(entry.file);
|
|
70
|
+
need((mode & 0o077) === 0 && uid === process.getuid(), 'VAST_SECRET_FILE_UNSAFE');
|
|
71
|
+
const key = (await readFile(entry.file, 'utf8')).trim();
|
|
72
|
+
need(/^[A-Za-z0-9_-]{16,256}$/.test(key), 'VAST_SECRET_INVALID');
|
|
73
|
+
return key;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Bounded child process without a shell. Output is collected, never echoed.
|
|
77
|
+
function defaultRun(command, args, { timeoutMs, cwd, stdoutFile, input, maxStdoutBytes } = {}) {
|
|
78
|
+
return new Promise(resolvePromise => {
|
|
79
|
+
const child = spawn(command, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
80
|
+
const chunks = [], errors = [];
|
|
81
|
+
let size = 0, overflow = false;
|
|
82
|
+
const sink = stdoutFile ? createWriteStream(stdoutFile, { flags: 'wx', mode: 0o600 }) : null;
|
|
83
|
+
const flushed = sink ? new Promise(done => { sink.on('close', done); sink.on('error', done); }) : Promise.resolve();
|
|
84
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), Math.max(1, timeoutMs ?? 10000));
|
|
85
|
+
if (sink) {
|
|
86
|
+
child.stdout.on('data', chunk => {
|
|
87
|
+
size += chunk.length;
|
|
88
|
+
if (maxStdoutBytes !== undefined && size > maxStdoutBytes && !overflow) { overflow = true; child.kill('SIGKILL'); }
|
|
89
|
+
});
|
|
90
|
+
child.stdout.pipe(sink);
|
|
91
|
+
}
|
|
92
|
+
else child.stdout.on('data', chunk => { size += chunk.length; if (size <= 8 << 20) chunks.push(chunk); });
|
|
93
|
+
child.stderr.on('data', chunk => { if (errors.length < 64) errors.push(chunk); });
|
|
94
|
+
child.on('error', () => {});
|
|
95
|
+
child.on('close', async code => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
await flushed;
|
|
98
|
+
resolvePromise({ code: overflow ? null : code, overflow, stdout: Buffer.concat(chunks).toString('utf8'), stderr: Buffer.concat(errors).toString('utf8') });
|
|
99
|
+
});
|
|
100
|
+
child.stdin.end(input ?? '');
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Tar numeric field: octal text, or GNU base-256 when the high bit is set.
|
|
105
|
+
function tarNumber(header, offset, length) {
|
|
106
|
+
const field = header.subarray(offset, offset + length);
|
|
107
|
+
if (field[0] & 0x80) {
|
|
108
|
+
let value = field[0] & 0x7f;
|
|
109
|
+
for (const byte of field.subarray(1)) value = value * 256 + byte;
|
|
110
|
+
need(Number.isSafeInteger(value), 'VAST_EVIDENCE_ARCHIVE');
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
const text = field.toString('latin1').replace(/[\0 ]+$/, '').trim();
|
|
114
|
+
need(/^[0-7]*$/.test(text), 'VAST_EVIDENCE_ARCHIVE');
|
|
115
|
+
return text ? parseInt(text, 8) : 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function evidenceName(name) {
|
|
119
|
+
const clean = name.replace(/\/+$/, '');
|
|
120
|
+
const parts = clean.split('/');
|
|
121
|
+
need(clean.length > 0 && clean.length <= 4096 && !clean.includes('\0') && !clean.includes('\\') && !isAbsolute(clean)
|
|
122
|
+
&& EVIDENCE_ROOTS.has(parts[0]) && parts.every(part => part !== '..' && part !== ''), 'VAST_EVIDENCE_UNSAFE_PATH');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Validate every header of a retrieved tar before extracting it: regular files
|
|
126
|
+
// and directories only (no links or devices), relative paths under out/ or
|
|
127
|
+
// run/ with no `..`, and bounded entry count and total size.
|
|
128
|
+
async function checkEvidenceArchive(path, { maxBytes, maxEntries }) {
|
|
129
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
130
|
+
try {
|
|
131
|
+
const { size } = await handle.stat();
|
|
132
|
+
need(size <= maxBytes, 'VAST_EVIDENCE_TOO_LARGE');
|
|
133
|
+
need(size > 0 && size % 512 === 0, 'VAST_EVIDENCE_ARCHIVE');
|
|
134
|
+
const header = Buffer.alloc(512);
|
|
135
|
+
const read = async (buffer, position) => {
|
|
136
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
137
|
+
need(bytesRead === buffer.length, 'VAST_EVIDENCE_ARCHIVE');
|
|
138
|
+
};
|
|
139
|
+
let offset = 0, entries = 0, total = 0, longName = null, paxPath = null, ended = false;
|
|
140
|
+
while (offset + 512 <= size) {
|
|
141
|
+
await read(header, offset); offset += 512;
|
|
142
|
+
if (header.every(byte => byte === 0)) { ended = true; break; }
|
|
143
|
+
let sum = 0;
|
|
144
|
+
for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : header[i];
|
|
145
|
+
need(sum === tarNumber(header, 148, 8), 'VAST_EVIDENCE_ARCHIVE');
|
|
146
|
+
const type = header[156] === 0 ? '0' : String.fromCharCode(header[156]);
|
|
147
|
+
const bodySize = tarNumber(header, 124, 12);
|
|
148
|
+
const padded = Math.ceil(bodySize / 512) * 512;
|
|
149
|
+
need(offset + padded <= size, 'VAST_EVIDENCE_ARCHIVE');
|
|
150
|
+
if (type === 'L' || type === 'x') {
|
|
151
|
+
need(bodySize <= 65536, 'VAST_EVIDENCE_ARCHIVE');
|
|
152
|
+
const body = Buffer.alloc(bodySize); await read(body, offset);
|
|
153
|
+
if (type === 'L') longName = body.toString('utf8').replace(/\0.*$/s, '');
|
|
154
|
+
else {
|
|
155
|
+
for (const record of body.toString('utf8').split('\n').filter(Boolean)) {
|
|
156
|
+
const match = /^[0-9]+ ([^=]+)=(.*)$/s.exec(record);
|
|
157
|
+
need(match, 'VAST_EVIDENCE_ARCHIVE');
|
|
158
|
+
if (match[1] === 'path') paxPath = match[2];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
offset += padded;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
need(type === '0' || type === '7' || type === '5', 'VAST_EVIDENCE_ENTRY_TYPE');
|
|
165
|
+
const ustar = header.subarray(257, 263).toString('latin1') === 'ustar\0';
|
|
166
|
+
const field = (start, length) => header.subarray(start, start + length).toString('utf8').replace(/\0.*$/s, '');
|
|
167
|
+
const prefix = ustar ? field(345, 155) : '';
|
|
168
|
+
const name = paxPath ?? longName ?? (prefix ? `${prefix}/${field(0, 100)}` : field(0, 100));
|
|
169
|
+
longName = null; paxPath = null;
|
|
170
|
+
evidenceName(name);
|
|
171
|
+
entries += 1; total += bodySize;
|
|
172
|
+
need(entries <= maxEntries, 'VAST_EVIDENCE_TOO_MANY_ENTRIES');
|
|
173
|
+
need(total <= maxBytes, 'VAST_EVIDENCE_TOO_LARGE');
|
|
174
|
+
offset += padded;
|
|
175
|
+
}
|
|
176
|
+
need(ended && entries > 0, 'VAST_EVIDENCE_ARCHIVE');
|
|
177
|
+
} finally { await handle.close(); }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function writePrivate(path, value) {
|
|
181
|
+
await writeFile(path, typeof value === 'string' ? value : JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function onstartScript(config, task = 'evaluate') {
|
|
185
|
+
const scripts = '/workspace/ruvllm/experiments/model-improvement/vast';
|
|
186
|
+
if (task === 'train') need(config.trainPayload, 'VAST_HOST_CONFIG_PAYLOAD');
|
|
187
|
+
const run = task === 'train'
|
|
188
|
+
? `${scripts}/remote-train.sh ${config.commit} ${config.armDeadlineSeconds} ${config.trainPayload}`
|
|
189
|
+
: `${scripts}/remote-run.sh ${config.commit} ${config.armDeadlineSeconds} ${config.bundle} ${config.adapterDir ?? 'coding-4b'}`;
|
|
190
|
+
const failed = `grep -q '"failed"' /workspace/run/status.json 2>/dev/null || printf '{"schema":"ruvllm-vast-remote-status/v1","state":"failed","step":"bootstrap"}\\n' > /workspace/run/status.json`;
|
|
191
|
+
return ['#!/bin/bash', 'mkdir -p /workspace/run',
|
|
192
|
+
`nohup bash -c '(export DEBIAN_FRONTEND=noninteractive; (command -v git >/dev/null || (apt-get update -qq && apt-get install -y -qq git >/dev/null)) && git clone --quiet ${config.repoUrl} /workspace/ruvllm && git -C /workspace/ruvllm checkout --quiet ${config.commit} && bash ${run}) >/workspace/run/remote.log 2>&1 || { ${failed.replaceAll("'", "'\\''")}; }' >/dev/null 2>&1 &`, ''].join('\n');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function createVastAdapter({ fetchImpl = globalThis.fetch, run = defaultRun, now = () => Date.now(), readConfig = readHostConfig } = {}) {
|
|
196
|
+
async function api(config, key, method, path, { body, query, signal } = {}) {
|
|
197
|
+
const url = new URL(config.apiBase + path);
|
|
198
|
+
for (const [name, value] of Object.entries(query ?? {})) url.searchParams.set(name, JSON.stringify(value));
|
|
199
|
+
const response = await fetchImpl(url, { method, signal, headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
200
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
201
|
+
if (response.status === 404) return null;
|
|
202
|
+
need(response.ok, `VAST_HTTP_${response.status}`);
|
|
203
|
+
return response.json();
|
|
204
|
+
}
|
|
205
|
+
async function instances(config, key, signal) {
|
|
206
|
+
const rows = [];
|
|
207
|
+
let after;
|
|
208
|
+
for (let page = 0; page < 40; page++) {
|
|
209
|
+
const data = await api(config, key, 'GET', '/api/v1/instances/', { signal,
|
|
210
|
+
query: { select_filters: {}, order_by: [{ col: 'id', dir: 'asc' }], limit: 25, ...(after ? { after_token: after } : {}) } });
|
|
211
|
+
rows.push(...(data?.instances ?? []));
|
|
212
|
+
if (!data?.next_token) return rows;
|
|
213
|
+
after = data.next_token;
|
|
214
|
+
}
|
|
215
|
+
fail('VAST_LIST_UNBOUNDED');
|
|
216
|
+
}
|
|
217
|
+
const attemptDir = (config, key) => join(config.resultsRoot, key);
|
|
218
|
+
// Host keys: verified against the operator's pinned known_hosts file; an
|
|
219
|
+
// unknown or changed key is refused. Trust on first use (a fresh per-instance
|
|
220
|
+
// file) only when the host config sets sshTrustOnFirstUse: true.
|
|
221
|
+
const hostKeyOptions = (config, instance) => config.sshKnownHosts !== undefined
|
|
222
|
+
? ['-o', 'StrictHostKeyChecking=yes', '-o', `UserKnownHostsFile=${config.sshKnownHosts}`, '-o', 'GlobalKnownHostsFile=/dev/null']
|
|
223
|
+
: ['-o', 'StrictHostKeyChecking=accept-new', '-o', `UserKnownHostsFile=${join(config.resultsRoot, `known_hosts-${instance.id}`)}`];
|
|
224
|
+
const ssh = (config, instance, remote) => ['-i', config.sshKey, '-p', String(instance.ssh_port), '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',
|
|
225
|
+
...hostKeyOptions(config, instance), '-o', 'IdentitiesOnly=yes',
|
|
226
|
+
`root@${instance.ssh_host}`, remote];
|
|
227
|
+
|
|
228
|
+
async function submit({ job, idempotencyKey, signal }) {
|
|
229
|
+
const config = await readConfig(), key = await apiKey(config, job.secretRef);
|
|
230
|
+
need(typeof job.resource.offerId === 'string' && /^[0-9]{1,12}$/.test(job.resource.offerId), 'VAST_OFFER_REQUIRED');
|
|
231
|
+
const label = `ruvllm-${idempotencyKey}`;
|
|
232
|
+
const existing = (await instances(config, key, signal)).find(row => row.label === label);
|
|
233
|
+
if (existing) return { runId: String(existing.id) };
|
|
234
|
+
// The bundles search ignores `id`; `ask_contract_id` is the offer identity filter.
|
|
235
|
+
const offers = (await api(config, key, 'POST', '/api/v0/bundles/', { signal, body: { ask_contract_id: { eq: Number(job.resource.offerId) },
|
|
236
|
+
rentable: { eq: true }, type: 'on-demand', allocated_storage: config.diskGb, limit: 2 } }))?.offers ?? [];
|
|
237
|
+
need(offers.length === 1 && String(offers[0].id) === job.resource.offerId
|
|
238
|
+
&& Number.isFinite(offers[0].dph_total) && offers[0].dph_total > 0, 'VAST_OFFER_UNAVAILABLE');
|
|
239
|
+
const offer = offers[0];
|
|
240
|
+
// Spending ceiling: the worst case (full timeout at the offer's total hourly
|
|
241
|
+
// price, storage included) must fit the job's reserved ceiling before renting.
|
|
242
|
+
const worstCaseUsd = ceilMicro(offer.dph_total * job.timeoutSeconds / 3600);
|
|
243
|
+
need(worstCaseUsd <= job.maxCostUsd, 'VAST_COST_CEILING');
|
|
244
|
+
await mkdir(attemptDir(config, idempotencyKey), { mode: 0o700 });
|
|
245
|
+
const created = await api(config, key, 'PUT', `/api/v0/asks/${offer.id}/`, { signal, body: { client_id: 'me', image: config.image,
|
|
246
|
+
env: {}, price: null, disk: config.diskGb, label, onstart: onstartScript(config, job.task), runtype: 'ssh', force: false, cancel_unavail: true } });
|
|
247
|
+
need(created?.success === true && Number.isSafeInteger(created.new_contract), 'VAST_CREATE_FAILED');
|
|
248
|
+
const instanceId = created.new_contract;
|
|
249
|
+
// Independent of this scheduler: a user timer destroys the rental after
|
|
250
|
+
// timeout + grace even if no further tick ever runs.
|
|
251
|
+
const guard = await run('systemd-run', ['--user', '--quiet', `--unit=ruvllm-vast-guard-${instanceId}`,
|
|
252
|
+
`--on-active=${job.timeoutSeconds + config.guardGraceSeconds}s`, `--setenv=${HOST_CONFIG_ENV}=${process.env[HOST_CONFIG_ENV] ?? ''}`,
|
|
253
|
+
config.node, SELF, 'guard', String(instanceId), job.secretRef], { timeoutMs: 8000 });
|
|
254
|
+
await writePrivate(join(attemptDir(config, idempotencyKey), 'submission.json'), { schema: 'ruvllm-vast-submission/v1',
|
|
255
|
+
jobId: job.id, idempotencyKey, label, instanceId, offerId: offer.id, gpuName: offer.gpu_name, dphTotal: offer.dph_total,
|
|
256
|
+
worstCaseUsd, maxCostUsd: job.maxCostUsd, timeoutSeconds: job.timeoutSeconds, image: config.image, commit: config.commit,
|
|
257
|
+
task: job.task, bundle: config.bundle, adapterDir: config.adapterDir ?? 'coding-4b', trainPayload: config.trainPayload ?? null, hostConfigSha256: config.sha256, guardArmed: guard.code === 0, submittedAt: new Date(now()).toISOString() });
|
|
258
|
+
if (guard.code !== 0) {
|
|
259
|
+
await api(config, key, 'DELETE', `/api/v0/instances/${instanceId}/`, { body: {} });
|
|
260
|
+
fail('VAST_GUARD_UNAVAILABLE');
|
|
261
|
+
}
|
|
262
|
+
return { runId: String(instanceId) };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 'ok' | 'retry' (slow or failed pull; next tick tries again) | 'rejected'
|
|
266
|
+
// (archive over the caps or with unsafe entries; never extracted).
|
|
267
|
+
async function retrieve(config, instance, dir, started) {
|
|
268
|
+
const left = () => INSPECT_BUDGET_MS - (now() - started);
|
|
269
|
+
const archive = join(dir, 'evidence.tar');
|
|
270
|
+
const maxBytes = config.maxEvidenceBytes ?? DEFAULT_MAX_EVIDENCE_BYTES;
|
|
271
|
+
const maxEntries = config.maxEvidenceEntries ?? DEFAULT_MAX_EVIDENCE_ENTRIES;
|
|
272
|
+
await rm(archive, { force: true }); await rm(join(dir, 'evidence'), { recursive: true, force: true });
|
|
273
|
+
if (left() < 4000) return 'retry';
|
|
274
|
+
const pulled = await run('ssh', ssh(config, instance, 'tar -C /workspace -cf - out run'), { timeoutMs: left() - 3000, stdoutFile: archive, maxStdoutBytes: maxBytes });
|
|
275
|
+
const reject = async reason => {
|
|
276
|
+
await rm(archive, { force: true });
|
|
277
|
+
await writePrivate(join(dir, 'evidence-rejected.json'), { schema: 'ruvllm-vast-evidence-rejected/v1', reason, maxBytes, maxEntries, at: new Date(now()).toISOString() });
|
|
278
|
+
return 'rejected';
|
|
279
|
+
};
|
|
280
|
+
if (pulled.overflow) return reject('VAST_EVIDENCE_TOO_LARGE');
|
|
281
|
+
if (pulled.code !== 0) return 'retry';
|
|
282
|
+
try { await checkEvidenceArchive(archive, { maxBytes, maxEntries }); }
|
|
283
|
+
catch (error) { if (error.code?.startsWith('VAST_EVIDENCE_')) return reject(error.code); throw error; }
|
|
284
|
+
await mkdir(join(dir, 'evidence'), { mode: 0o700 });
|
|
285
|
+
const extracted = await run('tar', ['-xf', archive, '-C', join(dir, 'evidence'), '--no-same-owner', '--no-same-permissions'], { timeoutMs: 5000 });
|
|
286
|
+
return extracted.code === 0 ? 'ok' : 'retry';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function verify(config, job, dir) {
|
|
290
|
+
const evidence = join(dir, 'evidence', 'out'), scored = join(dir, 'local-scored');
|
|
291
|
+
await rm(scored, { recursive: true, force: true });
|
|
292
|
+
const bundle = join(config.repoRoot, 'experiments/model-improvement/frozen', config.bundle);
|
|
293
|
+
const result = await run(config.python, [join(config.repoRoot, 'experiments/model-improvement/measurement.py'), '--bundle', bundle,
|
|
294
|
+
'--base-run', join(evidence, 'base'), '--adapter-run', join(evidence, 'adapter'), '--out', scored], { timeoutMs: 15000, cwd: config.repoRoot });
|
|
295
|
+
if (result.code !== 0) return { ok: false, reason: 'local_rescore_failed' };
|
|
296
|
+
const local = JSON.parse(await readFile(join(scored, 'report.json'), 'utf8'));
|
|
297
|
+
const remote = JSON.parse(await readFile(join(evidence, 'scored', 'report.json'), 'utf8'));
|
|
298
|
+
const ok = isDeepStrictEqual(local, remote) && local.evidence_mode === 'gpu-inference';
|
|
299
|
+
await writePrivate(join(dir, 'verification.json'), { schema: 'ruvllm-vast-verification/v1', jobId: job.id,
|
|
300
|
+
localRescoreMatchesRemote: isDeepStrictEqual(local, remote), evidenceMode: local.evidence_mode,
|
|
301
|
+
researchImprovementConfirmed: local.research_improvement_confirmed, reasons: local.reasons });
|
|
302
|
+
return { ok, reason: ok ? 'verified' : 'report_mismatch' };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Training evidence: the trainer's own manifest must hash every retrieved file,
|
|
306
|
+
// bind the committed payload, and match the completed event in the event log.
|
|
307
|
+
async function verifyTraining(config, job, dir) {
|
|
308
|
+
const out = join(dir, 'evidence', 'out', 'training-v1');
|
|
309
|
+
const sha = async path => createHash('sha256').update(await readFile(path)).digest('hex');
|
|
310
|
+
const checks = {};
|
|
311
|
+
try {
|
|
312
|
+
const bridge = JSON.parse(await readFile(join(config.repoRoot, config.trainPayload, 'bridge.json'), 'utf8'));
|
|
313
|
+
const manifestBytes = await readFile(join(out, 'manifest.json'));
|
|
314
|
+
const manifest = JSON.parse(manifestBytes);
|
|
315
|
+
const events = (await readFile(join(dir, 'evidence', 'run', 'train.log'), 'utf8')).split('\n')
|
|
316
|
+
.filter(line => line.startsWith('RUFLO_TRAIN ')).map(line => JSON.parse(line.slice(12)));
|
|
317
|
+
const completed = events.find(event => event.kind === 'completed');
|
|
318
|
+
checks.payloadBound = manifest.training_sha256 === bridge.training_sha256;
|
|
319
|
+
checks.manifestEvent = completed?.data?.manifest_sha256 === createHash('sha256').update(manifestBytes).digest('hex');
|
|
320
|
+
checks.steps = manifest.steps === 96 && manifest.revision === job.model.revision && manifest.model === job.model.repo;
|
|
321
|
+
const files = Object.entries(manifest.files ?? {});
|
|
322
|
+
checks.files = files.length >= 6 && (await Promise.all(files.map(async ([name, digest]) =>
|
|
323
|
+
!name.split('/').includes('..') && await sha(join(out, name)) === digest))).every(Boolean);
|
|
324
|
+
checks.validation = events.filter(event => event.kind === 'validation').map(event => event.data);
|
|
325
|
+
} catch (error) { checks.error = error.code ?? error.name; }
|
|
326
|
+
const ok = Boolean(checks.payloadBound && checks.manifestEvent && checks.steps && checks.files && !checks.error);
|
|
327
|
+
await writePrivate(join(dir, 'verification.json'), { schema: 'ruvllm-vast-training-verification/v1', jobId: job.id, ...checks, ok });
|
|
328
|
+
return { ok };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function inspect({ job, runId, idempotencyKey, signal }) {
|
|
332
|
+
const started = now(), config = await readConfig(), key = await apiKey(config, job.secretRef);
|
|
333
|
+
const instance = (await instances(config, key, signal)).find(row => String(row.id) === runId);
|
|
334
|
+
if (!instance) return { status: 'failed' };
|
|
335
|
+
const costUsd = ceilMicro(instance.dph_total * Math.max(0, now() / 1000 - (instance.start_date ?? now() / 1000)) / 3600);
|
|
336
|
+
if (costUsd > job.maxCostUsd) return { status: 'failed', costUsd };
|
|
337
|
+
// A host whose container runtime cannot start the image never recovers; fail now instead of at the deadline.
|
|
338
|
+
if (instance.actual_status !== 'running' && /error response from daemon|oci runtime|invalid runtime/i.test(instance.status_msg ?? '')) return { status: 'failed', costUsd };
|
|
339
|
+
if (instance.actual_status !== 'running' || !instance.ssh_host || !instance.ssh_port) return { status: 'running', costUsd };
|
|
340
|
+
const dir = attemptDir(config, idempotencyKey);
|
|
341
|
+
const probe = await run('ssh', ssh(config, instance, `cat /workspace/run/status.json && echo && { tail -c 400 /workspace/run/${job.task === 'train' ? 'train' : 'run-pair'}.log 2>/dev/null || true; }`), { timeoutMs: 10000 });
|
|
342
|
+
if (probe.code !== 0) return { status: 'running', costUsd };
|
|
343
|
+
let remote;
|
|
344
|
+
try { remote = JSON.parse(probe.stdout.split('\n')[0]); } catch { return { status: 'running', costUsd }; }
|
|
345
|
+
await writePrivate(join(dir, 'progress.json'), { at: new Date(now()).toISOString(), costUsd, remote, tail: probe.stdout.split('\n').slice(1).join('\n').slice(-400) });
|
|
346
|
+
if (remote.state === 'running') return { status: 'running', costUsd };
|
|
347
|
+
if (remote.state === 'failed') { await retrieve(config, instance, dir, started); return { status: 'failed', costUsd }; }
|
|
348
|
+
if (remote.state !== 'done') return { status: 'running', costUsd };
|
|
349
|
+
// Retain raw outputs locally before any terminal report; retry next tick on a
|
|
350
|
+
// slow pull; an oversized or unsafe archive fails the job.
|
|
351
|
+
const retrieved = await retrieve(config, instance, dir, started);
|
|
352
|
+
if (retrieved === 'rejected') return { status: 'failed', costUsd };
|
|
353
|
+
if (retrieved !== 'ok') return { status: 'running', costUsd };
|
|
354
|
+
const checked = job.task === 'train' ? await verifyTraining(config, job, dir) : await verify(config, job, dir);
|
|
355
|
+
return { status: 'completed', costUsd, completion: { completionVersion: 1, completionVerified: checked.ok, status: checked.ok ? 'ok' : 'error',
|
|
356
|
+
exitCode: checked.ok ? 0 : 1, assertionsVerified: checked.ok, evidenceComplete: checked.ok, truncated: false, jobId: job.id,
|
|
357
|
+
modelRepo: job.model.repo, modelRevision: job.model.revision, runId, idempotencyKey } };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function destroyConfirmed(config, key, instanceId, signal) {
|
|
361
|
+
await api(config, key, 'DELETE', `/api/v0/instances/${instanceId}/`, { body: {}, signal });
|
|
362
|
+
for (let check = 0; check < 6; check++) {
|
|
363
|
+
if (!(await instances(config, key, signal)).some(row => String(row.id) === String(instanceId))) return true;
|
|
364
|
+
await new Promise(done => setTimeout(done, 2500));
|
|
365
|
+
}
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function cleanup({ job, runId, signal }) {
|
|
370
|
+
const config = await readConfig(), key = await apiKey(config, job.secretRef);
|
|
371
|
+
const released = await destroyConfirmed(config, key, runId, signal);
|
|
372
|
+
if (released) await run('systemctl', ['--user', 'stop', `ruvllm-vast-guard-${runId}.timer`], { timeoutMs: 5000 });
|
|
373
|
+
await writePrivate(join(config.resultsRoot, `cleanup-${runId}.json`), { schema: 'ruvllm-vast-cleanup/v1', jobId: job.id,
|
|
374
|
+
instanceId: runId, confirmedAbsent: released, at: new Date(now()).toISOString() });
|
|
375
|
+
return { released };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return { submit, inspect, cleanup, destroyConfirmed, instances };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const defaults = createVastAdapter();
|
|
382
|
+
export const submit = input => defaults.submit(input);
|
|
383
|
+
export const inspect = input => defaults.inspect(input);
|
|
384
|
+
export const cleanup = input => defaults.cleanup(input);
|
|
385
|
+
|
|
386
|
+
// The destroy timer entry point. No top-level await, so this module can also be
|
|
387
|
+
// loaded with require() where Node supports require(esm).
|
|
388
|
+
async function guard(instanceId, ref) {
|
|
389
|
+
const config = await readHostConfig();
|
|
390
|
+
const released = await defaults.destroyConfirmed(config, await apiKey(config, ref), instanceId);
|
|
391
|
+
await writePrivate(join(config.resultsRoot, `guard-${instanceId}.json`), { schema: 'ruvllm-vast-guard/v1', instanceId,
|
|
392
|
+
confirmedAbsent: released, at: new Date().toISOString() });
|
|
393
|
+
return released;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (process.argv[1] === SELF && process.argv[2] === 'guard') {
|
|
397
|
+
const [instanceId, ref] = process.argv.slice(3);
|
|
398
|
+
guard(instanceId, ref).then(released => { process.exitCode = released ? 0 : 1; },
|
|
399
|
+
error => { console.error(error.code ?? 'VAST_GUARD_FAILED'); process.exitCode = 1; });
|
|
400
|
+
}
|