From 1fa4d4560eaa7bd1831703fb4ab57c7ed3861e7e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:48:11 -0500 Subject: [PATCH] Fix host-repo scheduler config resolution and handoff defaults --- scripts/agent/run-scheduler-cycle.mjs | 32 +++++- src/ops.mjs | 6 + test/ops.test.mjs | 10 ++ ...run-scheduler-cycle-memory-policy.test.mjs | 104 ++++++++++++++++++ ...uler-lock-failure-schema.contract.test.mjs | 1 + torch-config.example.json | 4 + 6 files changed, 153 insertions(+), 4 deletions(-) diff --git a/scripts/agent/run-scheduler-cycle.mjs b/scripts/agent/run-scheduler-cycle.mjs index 5c7f088..1df3b64 100644 --- a/scripts/agent/run-scheduler-cycle.mjs +++ b/scripts/agent/run-scheduler-cycle.mjs @@ -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} 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']), diff --git a/src/ops.mjs b/src/ops.mjs index 99587c5..1dc1409 100644 --- a/src/ops.mjs +++ b/src/ops.mjs @@ -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 = {}; diff --git a/test/ops.test.mjs b/test/ops.test.mjs index a75fb20..99c4849 100644 --- a/test/ops.test.mjs +++ b/test/ops.test.mjs @@ -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', + ); }); diff --git a/test/run-scheduler-cycle-memory-policy.test.mjs b/test/run-scheduler-cycle-memory-policy.test.mjs index b738e61..e01ef47 100644 --- a/test/run-scheduler-cycle-memory-policy.test.mjs +++ b/test/run-scheduler-cycle-memory-policy.test.mjs @@ -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' }, diff --git a/test/scheduler-lock-failure-schema.contract.test.mjs b/test/scheduler-lock-failure-schema.contract.test.mjs index 27b3868..bf9dfec 100644 --- a/test/scheduler-lock-failure-schema.contract.test.mjs +++ b/test/scheduler-lock-failure-schema.contract.test.mjs @@ -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'); diff --git a/torch-config.example.json b/torch-config.example.json index aafd0bf..e239c4a 100644 --- a/torch-config.example.json +++ b/torch-config.example.json @@ -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",