Fix host-repo scheduler config resolution and handoff defaults

This commit is contained in:
thePR0M3TH3AN committed 2026-02-25 14:48:11 -05:00
1 parent 723d9c8b53
commit 1fa4d4560e
6 files changed
+153 -4

No files matched your search

+28 -4
View File
@@ -41,6 +41,7 @@
// Source of truth: numbered MUST steps 2 and 4-16 in src/prompts/scheduler-flow.md are
// implemented by this script; step 3 (policy-file read) is best-effort and non-fatal.
import fs from 'node:fs/promises';
import fsSync from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
@@ -72,6 +73,7 @@ import {
} from './scheduler-lock.mjs';
import { detectPlatform } from '../../src/utils.mjs';
import { getTorchConfigPath } from '../../src/torch-config.mjs';
const VALID_CADENCES = new Set(['daily', 'weekly']);
const ALL_EXCLUDED_REASON = 'All roster tasks currently claimed by other agents';
@@ -328,9 +330,31 @@ function selectNextAgent({ roster, excludedSet, previousAgent, firstPrompt }) {
* @returns {Promise<Object>} Normalized scheduler config object.
*/
async function getSchedulerConfig(cadence, { isInteractive }) {
const cfg = await readJson(path.resolve(process.cwd(), 'torch-config.json'), {});
const configPath = getTorchConfigPath();
const cfg = await readJson(configPath, {});
const configDir = path.dirname(configPath);
const runtimeDir = process.cwd();
const resolveNodeCommand = (command) => {
if (typeof command !== 'string') return command;
const trimmed = command.trim();
if (!trimmed.startsWith('node ')) return trimmed;
const parts = trimmed.split(/\s+/);
if (parts.length < 2) return trimmed;
const scriptArg = parts[1];
if (!scriptArg || scriptArg.startsWith('-') || path.isAbsolute(scriptArg)) return trimmed;
const absoluteScriptPath = path.resolve(configDir, scriptArg);
if (!fsSync.existsSync(absoluteScriptPath)) return trimmed;
const rewrittenScriptPath = path.relative(runtimeDir, absoluteScriptPath).split(path.sep).join('/');
if (!rewrittenScriptPath || rewrittenScriptPath.startsWith('..')) return trimmed;
parts[1] = rewrittenScriptPath;
return parts.join(' ');
};
const scheduler = cfg.scheduler || {};
const handoffCommand = scheduler.handoffCommandByCadence?.[cadence] || null;
const defaultHandoffCommand = resolveNodeCommand('node scripts/agent/run-selected-prompt.mjs');
const handoffCommandRaw = scheduler.handoffCommandByCadence?.[cadence] || defaultHandoffCommand;
const handoffCommand = resolveNodeCommand(handoffCommandRaw);
const missingHandoffCommandForMode = !isInteractive && !handoffCommand;
const memoryPolicyRaw = scheduler.memoryPolicyByCadence?.[cadence] || {};
const mode = memoryPolicyRaw.mode === 'required' ? 'required' : 'optional';
@@ -390,10 +414,10 @@ async function getSchedulerConfig(cadence, { isInteractive }) {
memoryPolicy: {
mode,
retrieveCommand: typeof memoryPolicyRaw.retrieveCommand === 'string' && memoryPolicyRaw.retrieveCommand.trim()
? memoryPolicyRaw.retrieveCommand.trim()
? resolveNodeCommand(memoryPolicyRaw.retrieveCommand.trim())
: null,
storeCommand: typeof memoryPolicyRaw.storeCommand === 'string' && memoryPolicyRaw.storeCommand.trim()
? memoryPolicyRaw.storeCommand.trim()
? resolveNodeCommand(memoryPolicyRaw.storeCommand.trim())
: null,
retrieveSuccessMarkers: normalizeStringList(memoryPolicyRaw.retrieveSuccessMarkers, ['MEMORY_RETRIEVED']),
storeSuccessMarkers: normalizeStringList(memoryPolicyRaw.storeSuccessMarkers, ['MEMORY_STORED']),
+6
View File
@@ -381,9 +381,15 @@ function configureTorch(cwd, paths, installDir, namespace, relays, hashtag) {
// Configure memory policy with correct paths
if (!configData.scheduler) configData.scheduler = {};
if (!configData.scheduler.handoffCommandByCadence) {
configData.scheduler.handoffCommandByCadence = {};
}
// Always ensure memory policy exists and points to the correct scripts
// We use the installDir to construct the path
const scriptPrefix = installDir === '.' ? '' : `${installDir}/`;
const handoffPath = `node ${scriptPrefix}scripts/agent/run-selected-prompt.mjs`;
configData.scheduler.handoffCommandByCadence.daily = handoffPath;
configData.scheduler.handoffCommandByCadence.weekly = handoffPath;
if (!configData.scheduler.memoryPolicyByCadence) {
configData.scheduler.memoryPolicyByCadence = {};
+10
View File
@@ -182,4 +182,14 @@ test('cmdInit creates torch-config.json with random namespace', async () => {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
assert.strictEqual(config.nostrLock.namespace, 'test-namespace', 'Namespace should match mock');
assert.strictEqual(config.dashboard.hashtag, 'test-hashtag', 'Hashtag should match mock');
assert.strictEqual(
config.scheduler.handoffCommandByCadence.daily,
'node torch/scripts/agent/run-selected-prompt.mjs',
'daily handoff command should be configured for host installs',
);
assert.strictEqual(
config.scheduler.handoffCommandByCadence.weekly,
'node torch/scripts/agent/run-selected-prompt.mjs',
'weekly handoff command should be configured for host installs',
);
});
@@ -210,6 +210,110 @@ test('accepts required memory policy when markers or artifacts are produced', {
await fs.access(path.join(fixture.root, '.scheduler-memory', 'store-daily.ok'));
});
test('host-mode scheduler resolves parent config and rewrites torch-prefixed node commands', { concurrency: false }, async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduler-host-mode-'));
const torchDir = path.join(root, 'torch');
const scriptsDir = path.join(torchDir, 'scripts', 'agent');
const binDir = path.join(root, 'bin');
await fs.mkdir(path.join(torchDir, 'src', 'prompts', 'daily'), { recursive: true });
await fs.mkdir(path.join(torchDir, 'scripts', 'memory'), { recursive: true });
await fs.mkdir(scriptsDir, { recursive: true });
await fs.mkdir(binDir, { recursive: true });
try {
await fs.symlink(path.resolve('node_modules'), path.join(torchDir, 'node_modules'));
} catch {
// no-op
}
await fs.copyFile(SOURCE_SCRIPT, path.join(scriptsDir, 'run-scheduler-cycle.mjs'));
await fs.copyFile(path.resolve('scripts/agent/scheduler-utils.mjs'), path.join(scriptsDir, 'scheduler-utils.mjs'));
await fs.copyFile(path.resolve('scripts/agent/scheduler-lock.mjs'), path.join(scriptsDir, 'scheduler-lock.mjs'));
await fs.copyFile(path.resolve('src/utils.mjs'), path.join(torchDir, 'src', 'utils.mjs'));
await fs.copyFile(path.resolve('src/torch-config.mjs'), path.join(torchDir, 'src', 'torch-config.mjs'));
await fs.copyFile(path.resolve('src/constants.mjs'), path.join(torchDir, 'src', 'constants.mjs'));
await fs.copyFile(path.resolve('src/relay-health.mjs'), path.join(torchDir, 'src', 'relay-health.mjs'));
await fs.copyFile(path.resolve('src/lock-ops.mjs'), path.join(torchDir, 'src', 'lock-ops.mjs'));
await fs.writeFile(path.join(torchDir, 'scripts', 'agent', 'verify-run-artifacts.mjs'), '#!/usr/bin/env node\nprocess.exit(0);\n', 'utf8');
await fs.writeFile(path.join(torchDir, 'src', 'prompts', 'roster.json'), JSON.stringify({ daily: ['agent-a'] }, null, 2), 'utf8');
await fs.writeFile(path.join(torchDir, 'src', 'prompts', 'daily', 'agent-a.md'), '# agent-a\n', 'utf8');
await fs.writeFile(
path.join(torchDir, 'scripts', 'memory', 'retrieve.mjs'),
'import fs from "node:fs/promises"; await fs.mkdir(".scheduler-memory", { recursive: true }); await fs.writeFile(".scheduler-memory/retrieve-daily.ok", "ok\\n", "utf8"); console.log("MEMORY_RETRIEVED");\n',
'utf8',
);
await fs.writeFile(
path.join(torchDir, 'scripts', 'memory', 'store.mjs'),
'import fs from "node:fs/promises"; await fs.mkdir(".scheduler-memory", { recursive: true }); await fs.writeFile(".scheduler-memory/store-daily.ok", "ok\\n", "utf8"); console.log("MEMORY_STORED");\n',
'utf8',
);
await fs.writeFile(
path.join(torchDir, 'scripts', 'agent', 'run-selected-prompt.mjs'),
'#!/usr/bin/env node\nconsole.log("HANDOFF_OK");\n',
'utf8',
);
await fs.writeFile(
path.join(root, 'torch-config.json'),
JSON.stringify({
scheduler: {
firstPromptByCadence: { daily: 'agent-a' },
handoffCommandByCadence: { daily: 'node torch/scripts/agent/run-selected-prompt.mjs' },
validationCommandsByCadence: { daily: ['true'] },
memoryPolicyByCadence: {
daily: {
mode: 'required',
retrieveCommand: 'node torch/scripts/memory/retrieve.mjs',
storeCommand: 'node torch/scripts/memory/store.mjs',
retrieveSuccessMarkers: ['MEMORY_RETRIEVED'],
storeSuccessMarkers: ['MEMORY_STORED'],
retrieveArtifacts: ['.scheduler-memory/retrieve-daily.ok'],
storeArtifacts: ['.scheduler-memory/store-daily.ok'],
},
},
},
}, null, 2),
'utf8',
);
await fs.writeFile(
path.join(binDir, 'npm'),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" != "run" ]]; then
exit 1
fi
case "$2" in
lock:check:daily)
echo '{"excluded":[]}'
;;
lock:lock|lock:complete|lint)
;;
*)
;;
esac
`,
'utf8',
);
await fs.chmod(path.join(binDir, 'npm'), 0o755);
const result = await runNode(path.join(scriptsDir, 'run-scheduler-cycle.mjs'), ['--cadence', 'daily'], {
cwd: torchDir,
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
},
});
assert.equal(result.code, 0, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
assert.match(result.stdout, /Status:\s+completed/);
await fs.access(path.join(torchDir, '.scheduler-memory', 'retrieve-daily.ok'));
await fs.access(path.join(torchDir, '.scheduler-memory', 'store-daily.ok'));
});
test('records backend failure metadata when lock command exits with code 2', { concurrency: false }, async () => {
const fixture = await setupFixture({
memoryPolicy: { mode: 'optional' },
@@ -96,6 +96,7 @@ async function setupFixture({ lockShellBody }) {
await fs.copyFile(path.resolve('scripts/agent/scheduler-utils.mjs'), path.join(scriptsDir, 'scheduler-utils.mjs'));
await fs.copyFile(path.resolve('scripts/agent/scheduler-lock.mjs'), path.join(scriptsDir, 'scheduler-lock.mjs'));
await fs.copyFile(path.resolve('src/utils.mjs'), path.join(root, 'src', 'utils.mjs'));
await fs.copyFile(path.resolve('src/torch-config.mjs'), path.join(root, 'src', 'torch-config.mjs'));
await fs.copyFile(path.resolve('src/constants.mjs'), path.join(root, 'src', 'constants.mjs'));
await fs.writeFile(path.join(scriptsDir, 'verify-run-artifacts.mjs'), '#!/usr/bin/env node\nprocess.exit(0);\n', 'utf8');
await fs.writeFile(path.join(root, 'src', 'prompts', 'roster.json'), JSON.stringify({ daily: ['agent-a'] }, null, 2), 'utf8');
+4
View File
@@ -26,6 +26,10 @@
"daily": "scheduler-update-agent",
"weekly": "bug-reproducer-agent"
},
"handoffCommandByCadence": {
"daily": "node torch/scripts/agent/run-selected-prompt.mjs",
"weekly": "node torch/scripts/agent/run-selected-prompt.mjs"
},
"memoryPolicyByCadence": {
"daily": {
"mode": "required",