Add reversible system schedule launcher

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 18:08:52 -04:00
1 parent 1cf1f1b00f
commit fec36fba29
9 files changed
+342 -9

No files matched your search

+5 -3
View File
@@ -59,7 +59,9 @@ and the first Claude reference-fleet path:
- identity-bound context-usage telemetry that separates measurements from
estimates and relates cost/cache behavior to verified work;
- validated system/session schedules with shell-free actions, authority,
retries, durable run evidence, and explicit approval for mutation.
retries, durable run evidence, and explicit approval for mutation;
- an owner-approved user-systemd launcher for system schedules, bound to the
exact tracked configuration digest and recorded for exact reversal.
- durable Fleet evolution: the Session Manager can propose a justified new
persistent domain, while owner approval gates the configuration commit,
identity, prompt, branch, and worktree activation; it can also propose safe
@@ -67,8 +69,8 @@ and the first Claude reference-fleet path:
branch and refusing live, dirty, guarded, leased, or active work.
Live mixed-provider qualification, stable self-host bootstrapping, COMBATRIG
cutover, persistent system-schedule launcher integration, and public deployment
remain staged work.
cutover, installation of the implemented system-schedule launcher on this
machine, and public deployment remain staged work.
The canonical architecture and delivery plan is
[docs/PORTABLE_AGENT_FLEET_SPEC.md](docs/PORTABLE_AGENT_FLEET_SPEC.md).
+7 -2
View File
@@ -1133,8 +1133,13 @@ fields. The service plans without mutation, blocks session schedules while the
Session Manager is offline, requires explicit approval for mutating actions,
records durable run evidence, retries deterministically, and reports failures
through durable blocker messages. CLI and MCP surfaces share the service.
Cron definitions currently require an external launcher; persistent timer
installation remains open and will use the normal ownership manifest.
System schedules can now use an owner-approved user-systemd launcher. The
launcher dispatches once per minute, evaluates the validated cron/interval
definitions in TORCH, suppresses duplicate minute runs, and refuses to run
after tracked configuration changes because its command is bound to the exact
configuration digest. Unit files are recorded in the installation ownership
manifest and removal refuses changed files. Installing that persistent
launcher on a real machine remains an explicit owner action.
## 23. Repository and forge operating modes
+1
View File
@@ -29,6 +29,7 @@
"./observability": "./src/observability/snapshot.mjs",
"./resources": "./src/resources/service.mjs",
"./schedules": "./src/schedules/service.mjs",
"./schedules/launcher": "./src/schedules/launcher.mjs",
"./runtime/lifecycle": "./src/runtime/lifecycle.mjs",
"./self-host": "./src/self-host/service.mjs",
"./telemetry/context": "./src/telemetry/context.mjs"
@@ -186,6 +186,10 @@ test_integrity_note:
given: system and session schedules with different owners, behavior, retries, and authority
when: schedules are planned and executed through a virtualized shell-free runner
then: offline sessions and unauthorized actors are blocked, mutation needs approval, failures retry exactly and notify durably, and each run is recorded
- id: SCN-system-schedule-launcher
given: an installed Fleet with an owner-approved system schedule
when: TORCH plans, installs, dispatches, detects drift, and removes its user-systemd launcher in a temporary config home
then: units are bound to the exact tracked config digest, duplicate minute dispatch is suppressed, systemctl is virtualized, ownership is manifest-recorded, and reversal removes only unchanged TORCH units
- id: SCN-cli-schedules
given: an installed Fleet with an active Session Manager and a read-only session schedule
when: public CLI commands list, plan, and run it
+35
View File
@@ -25,6 +25,7 @@ import { CANDIDATE_ACCEPTANCE_SCENARIOS, createVersionService } from './self-hos
import { observeProject } from './observability/snapshot.mjs';
import { ContextTelemetryService } from './telemetry/context.mjs';
import { ScheduleService } from './schedules/service.mjs';
import { assertLauncherDigest, ScheduleLauncherService } from './schedules/launcher.mjs';
import { createFleetDesignBrief } from './design/brief.mjs';
import {
createArchitectRunPlan, loadArchitectArtifact, runSessionArchitect, validateArchitectProposal,
@@ -103,6 +104,7 @@ Usage:
torch schedules runs [--id <schedule>] [--json]
torch schedules plan --id <schedule> --actor <id|owner> [--json]
torch schedules run --id <schedule> --actor <id|owner> [--yes] [--json]
torch schedules launcher <plan|status|install|remove> [--yes] [--json]
torch fleet changes [--state <state>] [--json]
torch fleet get --change <id> [--json]
torch fleet propose --from session-manager --proposal <path> [--json]
@@ -747,11 +749,44 @@ export async function runCli(argv = process.argv.slice(2), {
}
if (command === 'schedules') {
const operation = argv[1] ?? 'list';
if (operation === 'launcher') {
const launcherOperation = argv[2] ?? 'status';
const launcher = new ScheduleLauncherService({
repositoryRoot: repository.root, env,
command: [process.execPath, new URL('../bin/torch.mjs', import.meta.url).pathname],
});
if (launcherOperation === 'plan') {
const plan = launcher.plan();
print(plan, { json });
return plan.canProceed ? 0 : 1;
}
if (launcherOperation === 'status') {
print(launcher.status(), { json });
return 0;
}
if (!['install', 'remove'].includes(launcherOperation)) {
throw new TorchError(`Unknown schedule launcher operation: ${launcherOperation}`, { code: 'UNKNOWN_COMMAND' });
}
if (!argv.includes('--yes')) {
throw new TorchError('Persistent schedule launcher changes user systemd state. Review the plan, then re-run with --yes.', {
code: 'APPROVAL_REQUIRED',
});
}
print(launcherOperation === 'install' ? launcher.install() : launcher.remove(), { json });
return 0;
}
const control = openControlPlane({ repositoryRoot: repository.root, env });
try {
const schedules = new ScheduleService({ repositoryRoot: repository.root, controlPlane: control });
const scheduleId = optionValue(argv, '--id');
const actorId = optionValue(argv, '--actor') ?? 'owner';
if (operation === 'dispatch-system') {
if (!argv.includes('--yes')) throw new TorchError('System schedule dispatch requires installed-launcher approval.', { code: 'APPROVAL_REQUIRED' });
assertLauncherDigest(repository.root, optionValue(argv, '--launcher-digest'));
const dispatched = schedules.dispatchSystem({ actorId: 'owner', approved: true });
print(dispatched, { json });
return dispatched.runs.every((run) => run.result === 'succeeded') ? 0 : 1;
}
if (operation === 'list') print({ schedules: schedules.list({ actorId }) }, { json });
else if (operation === 'runs') print({ runs: schedules.runs({ scheduleId }) }, { json });
else if (operation === 'plan') print(schedules.plan({ scheduleId, actorId }), { json });
+3
View File
@@ -287,6 +287,9 @@ export function planUninstall({ repository, purge = false }) {
const state = inspectManagedWorktree(repository.root, entry, mainBranch);
if (!state.safeToRemove) problems.push({ type: 'unsafe-worktree', path: entry.path, area: entry.area, problems: state.problems });
}
if (entry.type === 'systemd-user-unit') {
problems.push({ type: 'persistent-integration-installed', path: entry.path, name: entry.name });
}
}
return {
action: purge ? 'purge' : 'detach', manifest, problems,
+171
View File
@@ -0,0 +1,171 @@
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { TorchError } from '../kernel/errors.mjs';
import { readInstallManifest, writeInstallManifest } from '../kernel/install.mjs';
import { loadProjectConfig } from '../kernel/config.mjs';
function sha256(content) {
return createHash('sha256').update(content).digest('hex');
}
function unitQuote(value) {
return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
}
function configPath(repositoryRoot) {
return join(repositoryRoot, '.torch', 'torch.yaml');
}
export function scheduleConfigDigest(repositoryRoot) {
return sha256(readFileSync(configPath(repositoryRoot)));
}
function unitDirectory(env) {
return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'systemd', 'user');
}
function render({ repositoryRoot, projectId, command, digest, env }) {
const base = `torch-${projectId}-schedules`;
const serviceName = `${base}.service`;
const timerName = `${base}.timer`;
const directory = unitDirectory(env);
const args = [
...command, 'schedules', 'dispatch-system', '--launcher-digest', digest, '--yes', '--json',
];
const environment = env.XDG_DATA_HOME
? `Environment=${unitQuote(`XDG_DATA_HOME=${resolve(env.XDG_DATA_HOME)}`)}\n` : '';
const service = [
'[Unit]', `Description=TORCH system schedules for ${projectId}`, '', '[Service]', 'Type=oneshot',
`WorkingDirectory=${unitQuote(repositoryRoot)}`, environment.trimEnd(),
`ExecStart=${args.map(unitQuote).join(' ')}`, '',
].filter((line) => line !== '').join('\n') + '\n';
const timer = [
'[Unit]', `Description=Dispatch TORCH system schedules for ${projectId}`, '', '[Timer]',
'OnCalendar=*-*-* *:*:00', 'Persistent=true', `Unit=${serviceName}`, '', '[Install]',
'WantedBy=timers.target', '',
].join('\n');
return {
serviceName, timerName, directory,
files: [
{ name: serviceName, path: join(directory, serviceName), content: service },
{ name: timerName, path: join(directory, timerName), content: timer },
],
};
}
function runSystemctl(executor, args) {
const result = executor('systemctl', ['--user', ...args], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: false,
});
if (result.status !== 0 || result.error) {
throw new TorchError(`systemctl --user ${args.join(' ')} failed`, {
code: 'SCHEDULE_LAUNCHER_SYSTEMD_FAILED', details: result.stderr?.toString().trim() || result.error?.message,
});
}
}
export class ScheduleLauncherService {
constructor({
repositoryRoot, env = process.env, command,
executor = (file, args, options) => spawnSync(file, args, options),
} = {}) {
this.repositoryRoot = resolve(repositoryRoot);
this.env = env;
this.command = command ?? [process.execPath, new URL('../../bin/torch.mjs', import.meta.url).pathname];
this.executor = executor;
}
plan() {
const config = loadProjectConfig(this.repositoryRoot);
const manifest = readInstallManifest(this.repositoryRoot);
const systemSchedules = config.schedules.filter((schedule) => schedule.lifetime === 'system');
const digest = scheduleConfigDigest(this.repositoryRoot);
const rendered = render({
repositoryRoot: this.repositoryRoot, projectId: manifest.projectId,
command: this.command, digest, env: this.env,
});
const blockers = [];
if (!systemSchedules.length) blockers.push({ code: 'NO_SYSTEM_SCHEDULES' });
for (const file of rendered.files) if (existsSync(file.path)) blockers.push({ code: 'LAUNCHER_PATH_EXISTS', path: file.path });
return {
action: 'install-system-schedule-launcher', projectId: manifest.projectId,
systemSchedules: systemSchedules.map((schedule) => schedule.id), digest,
...rendered, blockers, canProceed: blockers.length === 0, mutationPerformed: false,
};
}
install() {
const plan = this.plan();
if (!plan.canProceed) throw new TorchError('System schedule launcher installation is blocked', {
code: 'SCHEDULE_LAUNCHER_BLOCKED', details: plan.blockers,
});
const manifest = readInstallManifest(this.repositoryRoot);
const originalManifest = structuredClone(manifest);
const created = [];
try {
mkdirSync(plan.directory, { recursive: true });
for (const file of plan.files) {
const temporary = `${file.path}.tmp-${process.pid}`;
writeFileSync(temporary, file.content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
renameSync(temporary, file.path);
created.push(file.path);
manifest.external.push({
type: 'systemd-user-unit', name: file.name, path: file.path,
sha256: sha256(file.content), configDigest: plan.digest,
});
}
writeInstallManifest(this.repositoryRoot, manifest);
runSystemctl(this.executor, ['daemon-reload']);
runSystemctl(this.executor, ['enable', '--now', plan.timerName]);
return { ...plan, mutationPerformed: true, requiresCommit: ['.torch/install-manifest.json'] };
} catch (error) {
try { runSystemctl(this.executor, ['disable', '--now', plan.timerName]); } catch { /* best-effort rollback */ }
for (const path of created.reverse()) if (existsSync(path)) rmSync(path);
writeInstallManifest(this.repositoryRoot, originalManifest);
throw error;
}
}
status() {
const manifest = readInstallManifest(this.repositoryRoot);
const units = (manifest.external ?? []).filter((entry) => entry.type === 'systemd-user-unit');
return {
installed: units.length > 0,
units: units.map((entry) => ({ ...entry, exists: existsSync(entry.path) })),
currentConfigDigest: scheduleConfigDigest(this.repositoryRoot),
stale: units.some((entry) => entry.configDigest !== scheduleConfigDigest(this.repositoryRoot)),
mutationPerformed: false,
};
}
remove() {
const manifest = readInstallManifest(this.repositoryRoot);
const units = (manifest.external ?? []).filter((entry) => entry.type === 'systemd-user-unit');
if (!units.length) return { removed: [], changed: false, mutationPerformed: false };
const modified = units.filter((entry) => !existsSync(entry.path) || sha256(readFileSync(entry.path)) !== entry.sha256);
if (modified.length) throw new TorchError('Refusing to remove changed or missing system schedule launcher files', {
code: 'SCHEDULE_LAUNCHER_MODIFIED', details: modified.map((entry) => entry.path),
});
const timer = units.find((entry) => entry.name.endsWith('.timer'));
if (timer) runSystemctl(this.executor, ['disable', '--now', timer.name]);
for (const entry of units) rmSync(entry.path);
manifest.external = manifest.external.filter((entry) => entry.type !== 'systemd-user-unit');
writeInstallManifest(this.repositoryRoot, manifest);
runSystemctl(this.executor, ['daemon-reload']);
return {
removed: units.map((entry) => entry.path), changed: true, mutationPerformed: true,
requiresCommit: ['.torch/install-manifest.json'],
};
}
}
export function assertLauncherDigest(repositoryRoot, expected) {
const actual = scheduleConfigDigest(repositoryRoot);
if (!expected || expected !== actual) throw new TorchError('System schedule launcher is stale for the tracked configuration', {
code: 'SCHEDULE_LAUNCHER_STALE', details: { expected: expected ?? null, actual },
});
return actual;
}
+50 -1
View File
@@ -3,6 +3,39 @@ import { spawnSync } from 'node:child_process';
import { loadFleetDefinition } from '../kernel/worktrees.mjs';
import { TorchError } from '../kernel/errors.mjs';
function cronPartMatches(part, value, { sunday = false } = {}) {
return part.split(',').some((segment) => {
const [rangeText, stepText] = segment.split('/');
const step = stepText === undefined ? 1 : Number(stepText);
if (!Number.isInteger(step) || step < 1) return false;
const normalize = (number) => (sunday && number === 7 ? 0 : number);
if (rangeText === '*') return value % step === 0;
const [startText, endText] = rangeText.split('-');
const start = normalize(Number(startText));
const end = endText === undefined ? start : normalize(Number(endText));
if (!Number.isInteger(start) || !Number.isInteger(end) || value < start || value > end) return false;
return (value - start) % step === 0;
});
}
export function cronMatches(expression, at) {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) return false;
const [minute, hour, day, month, weekday] = parts;
if (!cronPartMatches(minute, at.getMinutes()) || !cronPartMatches(hour, at.getHours())
|| !cronPartMatches(month, at.getMonth() + 1)) return false;
const dayMatches = cronPartMatches(day, at.getDate());
const weekdayMatches = cronPartMatches(weekday, at.getDay(), { sunday: true });
if (day === '*' && weekday === '*') return true;
if (day === '*') return weekdayMatches;
if (weekday === '*') return dayMatches;
return dayMatches || weekdayMatches;
}
function sameMinute(left, right) {
return left && right && left.slice(0, 16) === right.slice(0, 16);
}
function text(value, field) {
if (typeof value !== 'string' || !value.trim()) {
throw new TorchError(`Schedule ${field} is required`, { code: 'SCHEDULE_CONFIG_INVALID', details: { field } });
@@ -125,7 +158,10 @@ export class ScheduleService {
nextAt = new Date(Date.parse(lastRun.startedAt) + definition.trigger.seconds * 1000).toISOString();
due = at.getTime() >= Date.parse(nextAt);
}
if (definition.trigger.type === 'cron') due = 'external-launcher';
if (definition.trigger.type === 'cron') {
due = cronMatches(definition.trigger.expression, at)
&& !sameMinute(lastRun?.startedAt, at.toISOString());
}
return {
schedule: definition, actorId, due, nextAt, blockers,
canRun: blockers.length === 0, mutationPerformed: false,
@@ -175,4 +211,17 @@ export class ScheduleService {
list({ actorId } = {}) {
return [...this.definitions.values()].map((definition) => this.plan({ scheduleId: definition.id, actorId }));
}
dispatchSystem({ actorId = 'owner', approved = false, at = this.clock() } = {}) {
const considered = [...this.definitions.values()].filter((definition) => definition.lifetime === 'system');
const due = considered.filter((definition) => {
if (definition.trigger.type === 'manual') return false;
return this.plan({ scheduleId: definition.id, actorId, at }).due === true;
});
const runs = due.map((definition) => this.run({ scheduleId: definition.id, actorId, approved }));
return {
at: at.toISOString(), considered: considered.map((definition) => definition.id),
due: due.map((definition) => definition.id), runs, mutationPerformed: runs.length > 0,
};
}
}
+66 -3
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
@@ -8,8 +8,9 @@ import { openControlPlane } from '../../src/control-plane/service.mjs';
import { analyzeRepository } from '../../src/kernel/analyze.mjs';
import { proposeDomains } from '../../src/kernel/domains.mjs';
import { inspectRepository } from '../../src/kernel/git.mjs';
import { installProject } from '../../src/kernel/install.mjs';
import { installProject, planUninstall } from '../../src/kernel/install.mjs';
import { ScheduleService } from '../../src/schedules/service.mjs';
import { assertLauncherDigest, ScheduleLauncherService } from '../../src/schedules/launcher.mjs';
const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname;
@@ -50,7 +51,11 @@ function fixture() {
proposal.review = {
status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [],
};
const env = { ...process.env, XDG_DATA_HOME: mkdtempSync(join(tmpdir(), 'torch-schedules-state-')) };
const env = {
...process.env,
XDG_DATA_HOME: mkdtempSync(join(tmpdir(), 'torch-schedules-state-')),
XDG_CONFIG_HOME: mkdtempSync(join(tmpdir(), 'torch-schedules-config-')),
};
installProject({ repository, proposal, env, projectId: 'schedule-fixture' });
return { root, env, worker };
}
@@ -103,3 +108,61 @@ test('SCN-cli-schedules: public commands list, plan, and run a read-only session
assert.equal(executed.status, 0, executed.stderr || executed.stdout);
assert.equal(JSON.parse(executed.stdout).stdout, 'healthy');
});
test('SCN-system-schedule-launcher: exact-config user units install, dispatch, and reverse safely', () => {
const context = fixture();
const systemctl = [];
const launcher = new ScheduleLauncherService({
repositoryRoot: context.root, env: context.env,
command: ['/opt/torch/node', '/opt/torch/bin/torch.mjs'],
executor: (command, args, options) => {
systemctl.push({ command, args, options });
return { status: 0, stdout: '', stderr: '' };
},
});
const plan = launcher.plan();
assert.equal(plan.canProceed, true);
assert.deepEqual(plan.systemSchedules, ['release']);
assert.equal(plan.files.every((file) => !existsSync(file.path)), true);
const installed = launcher.install();
assert.equal(installed.mutationPerformed, true);
assert.equal(installed.files.every((file) => existsSync(file.path)), true);
assert.match(readFileSync(installed.files[0].path, 'utf8'), new RegExp(installed.digest));
assert.deepEqual(systemctl.slice(0, 2).map((call) => call.args.slice(0, 2)), [
['--user', 'daemon-reload'], ['--user', 'enable'],
]);
assert.equal(launcher.status().stale, false);
assert.equal(planUninstall({ repository: inspectRepository(context.root), purge: true }).problems
.some((problem) => problem.type === 'persistent-integration-installed'), true);
const originalConfig = readFileSync(join(context.root, '.torch', 'torch.yaml'), 'utf8');
writeFileSync(join(context.root, '.torch', 'torch.yaml'), `${originalConfig.trimEnd()} \n`);
assert.throws(
() => assertLauncherDigest(context.root, installed.digest),
(error) => error.code === 'SCHEDULE_LAUNCHER_STALE',
);
writeFileSync(join(context.root, '.torch', 'torch.yaml'), originalConfig);
const removed = launcher.remove();
assert.equal(removed.mutationPerformed, true);
assert.equal(installed.files.every((file) => !existsSync(file.path)), true);
assert.equal(launcher.status().installed, false);
const control = openControlPlane({ repositoryRoot: context.root, env: context.env });
let at = new Date(2026, 8, 27, 16, 0, 0);
const executed = [];
const schedules = new ScheduleService({
repositoryRoot: context.root, controlPlane: control, clock: () => at,
executor: (command, args) => {
executed.push({ command, args });
return { status: 0, stdout: 'released', stderr: '' };
},
});
const first = schedules.dispatchSystem({ actorId: 'owner', approved: true, at });
assert.deepEqual(first.due, ['release']);
assert.equal(executed.length, 1);
at = new Date(2026, 8, 27, 16, 0, 30);
assert.deepEqual(schedules.dispatchSystem({ actorId: 'owner', approved: true, at }).due, []);
control.close();
});