diff --git a/docs/agents/BITVID-SCHEDULER.md b/docs/agents/BITVID-SCHEDULER.md new file mode 100644 index 00000000..1b60e1ee --- /dev/null +++ b/docs/agents/BITVID-SCHEDULER.md @@ -0,0 +1,15 @@ +# Bitvid scheduled-job policy + +Owner direction, 2026-10-23: "I would like to not have extra processes like this running when the fleet is down or paused". + +All seven installed Bitvid cron jobs enter through `tools/fleet/bin/bitvid-schedule-guard.mjs`, not directly through `sched.sh`. + +- A missing/dead/stopped interactive fleet means no scheduled headless launch. At least one configured interactive area must have a running record and a live PID. +- `fleet/PAUSED` is represented by the existing state-directory `PAUSED` marker. It unconditionally blocks every job, including the weekly restart job; `ignorePause` does not bypass this outer guard. +- A running scheduled job is checked every second. On pause/down, the guard sends TERM only to the process group it created. Fleet's nested process runners forward termination to their own agent group. A bounded KILL fallback handles a group that does not exit. +- A cron tick while down or paused starts only the brief guard check, which exits without an AI session or persistent watcher. There is no always-on supervisor added. +- Unpausing and bringing the fleet up lets the next normal cron tick run; this policy does not automatically resume a paused fleet. +- Existing quiet hours, per-job non-overlap locks and time limits still apply inside `sched.sh`. The one-heavy-job convention still applies. +- This is a Bitvid-specific file, separate from the kit-managed scheduler. Kit upgrades should retain it. `fleet crontab` prints generic, unguarded commands: do not overwrite the installed guarded entries without preserving this policy. + +Validation: `env -u NODE_TEST_CONTEXT node --test tools/fleet/test/bitvid-schedule-guard.test.mjs` passed 8 tests with natural exit; fixtures only, no real fleet shutdown/pause or headless model run. Test integrity: new behavioral coverage, no existing assertions/gates changed. Process stop tests use a disposable sleep child and verify the unrelated test parent remains alive. Nested production runner cleanup relies on the kit's existing TERM forwarding; this is not a real-model stop qualification. diff --git a/tools/fleet/bin/bitvid-schedule-guard.mjs b/tools/fleet/bin/bitvid-schedule-guard.mjs new file mode 100644 index 00000000..66d51c59 --- /dev/null +++ b/tools/fleet/bin/bitvid-schedule-guard.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +// Bitvid policy: cron may work only while an interactive fleet is alive and unpaused. +// Kept separate from sched.sh so kit updates cannot restore ignorePause behavior. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadConfig, pidAlive, killProcessGroup, runProcessGroup } from './lib.mjs'; + +export function inactiveReason(c) { + if (fs.existsSync(path.join(c.stateDir, 'PAUSED'))) return 'fleet paused'; + for (const area of c.areas) { + try { + const run = JSON.parse(fs.readFileSync(path.join(c.stateDir, 'runs', `${area.id}.json`), 'utf8')); + if (run.status === 'running' && Number.isInteger(run.pid) && run.pid > 1 && pidAlive(run.pid)) return null; + } catch { /* Missing or invalid state is not permission to launch. */ } + } + return 'fleet down'; +} + +export async function guardSchedule(c, job, { scheduler = path.join(path.dirname(fileURLToPath(import.meta.url)), 'sched.sh'), pollMs = 1000 } = {}) { + if (!c.schedules.some((entry) => entry.name === job)) throw new Error(`Unknown scheduled job: ${job}`); + const reason = inactiveReason(c); + if (reason) return { status: 0, reason }; + let poll; + let force; + let stopped; + const result = await runProcessGroup({ + cmd: '/bin/bash', args: [scheduler, job], cwd: c.repo, env: process.env, + onSpawn(pid) { + poll = setInterval(() => { + const why = inactiveReason(c); + if (!why || stopped) return; + stopped = why; + // Only the process group this invocation created. Nested fleet runners + // receive TERM and forward it to their own agent process groups. + killProcessGroup(pid, 'SIGTERM'); + force = setTimeout(() => killProcessGroup(pid, 'SIGKILL'), 3000); + }, pollMs); + }, + }).finally(() => { + clearInterval(poll); + clearTimeout(force); + }); + return { status: stopped ? 0 : (result.status ?? 1), reason: stopped ?? null }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const c = loadConfig(); + const result = await guardSchedule(c, process.argv[2]); + process.exitCode = result.status; + } catch (error) { + console.error(`Bitvid schedule guard: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/tools/fleet/test/bitvid-schedule-guard.test.mjs b/tools/fleet/test/bitvid-schedule-guard.test.mjs new file mode 100644 index 00000000..6a54c452 --- /dev/null +++ b/tools/fleet/test/bitvid-schedule-guard.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { guardSchedule } from '../bin/bitvid-schedule-guard.mjs'; + +const moduleUrl = new URL('../bin/bitvid-schedule-guard.mjs', import.meta.url).href; +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bitvid-schedule-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const c = { repo: root, stateDir: root, areas: [{ id: 'dispatch' }], schedules: [{ name: 'resources' }, { name: 'restart' }] }; + fs.mkdirSync(path.join(root, 'runs')); + const record = path.join(root, 'runs', 'dispatch.json'); + const scheduler = path.join(root, 'schedule.sh'); + fs.writeFileSync(scheduler, 'echo started\nprintf yes > "'+root+'/launched"\n'); + const live = () => fs.writeFileSync(record, JSON.stringify({ status: 'running', pid: process.pid })); + return { c, root, record, scheduler, live }; +} + +for (const job of ['resources', 'restart']) { + test(`${job} does not launch while down`, async (t) => { + const f = fixture(t); + assert.deepEqual(await guardSchedule(f.c, job, { scheduler: f.scheduler }), { status: 0, reason: 'fleet down' }); + assert.equal(fs.existsSync(path.join(f.root, 'launched')), false); + }); + test(`${job} cannot bypass pause even with a live fleet`, async (t) => { + const f = fixture(t); f.live(); fs.writeFileSync(path.join(f.root, 'PAUSED'), 'owner pause'); + assert.deepEqual(await guardSchedule(f.c, job, { scheduler: f.scheduler }), { status: 0, reason: 'fleet paused' }); + assert.equal(fs.existsSync(path.join(f.root, 'launched')), false); + }); +} + +test('stopped record pointing at a live unrelated process cannot start a job', async (t) => { + const f = fixture(t); + fs.writeFileSync(f.record, JSON.stringify({ status: 'stopped', pid: process.pid })); + assert.equal((await guardSchedule(f.c, 'resources', { scheduler: f.scheduler })).reason, 'fleet down'); +}); + +test('live unpaused fleet launches a short job and exits naturally', async (t) => { + const f = fixture(t); f.live(); + assert.deepEqual(await guardSchedule(f.c, 'resources', { scheduler: f.scheduler }), { status: 0, reason: null }); + assert.equal(fs.readFileSync(path.join(f.root, 'launched'), 'utf8'), 'yes'); +}); + +for (const transition of ['pause', 'down']) { + test(`already-running scheduled child is stopped on ${transition}`, { timeout: 5000 }, async (t) => { + const f = fixture(t); f.live(); + fs.writeFileSync(f.scheduler, 'echo started\nexec sleep 600\n'); + const code = `import {guardSchedule} from ${JSON.stringify(moduleUrl)}; const r=await guardSchedule(${JSON.stringify(f.c)},'resources',${JSON.stringify({scheduler:f.scheduler,pollMs:25})}); console.log(JSON.stringify(r)); process.exitCode=r.status;`; + const child = spawn(process.execPath, ['--input-type=module', '-e', code], { stdio: ['ignore','pipe','pipe'] }); + t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); }); + let output = ''; + const exited = new Promise((resolve, reject) => { child.once('error',reject); child.once('close',(status)=>resolve(status)); }); + await new Promise((resolve, reject) => { + child.once('error', reject); + child.stdout.on('data', chunk => { output += chunk; if (output.includes('started\n')) resolve(); }); + child.once('exit', () => { if (!output.includes('started\n')) reject(new Error('child exited before ready')); }); + }); + if (transition === 'pause') fs.writeFileSync(path.join(f.root,'PAUSED'),'pause'); + else fs.writeFileSync(f.record, JSON.stringify({status:'stopped',pid:process.pid})); + assert.equal(await exited, 0); + assert.match(output, transition === 'pause' ? /fleet paused/ : /fleet down/); + assert.doesNotThrow(() => process.kill(process.pid, 0)); + }); +}