Decomposed scripts/agent/run-scheduler-cycle.mjs into modular components

Extracted logic into scheduler-config.mjs, scheduler-logger.mjs, and scheduler-state.mjs to improve maintainability and reduce file size (1160 -> 827 lines). Corrected module imports and verified via linting and dry-run usage.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-27 00:15:50 +00:00
1 parent dd7cc94090
commit 74950ca1a9
10 files changed
+520 -351

No files matched your search

@@ -0,0 +1,12 @@
# Memory Update — decompose-agent — 2026-02-27
## Key findings
- `scripts/check-file-size.mjs` is effective for identifying decomposition candidates, but HTML and JSON files should be manually filtered out for logic-based refactors.
- `scripts/agent/run-scheduler-cycle.mjs` is the central orchestration script and is growing in complexity.
## Patterns / reusable knowledge
- Extracting configuration and logging into separate modules (`scheduler-config.mjs`, `scheduler-logger.mjs`) is a clean pattern for reducing CLI script size.
- Using `node:fs/promises` and `node:path` consistently helps with modern ESM decomposition.
## Warnings / gotchas
- `scripts/check-file-size.mjs` does not have an `--update` flag to automatically rewrite baselines; this must be done manually or noted as a limitation.
+18 -351
View File
@@ -41,20 +41,14 @@
// 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';
import {
runCommand,
parseJsonFromOutput,
readJson,
normalizeStringList,
parseNonNegativeInt,
parseBooleanFlag,
excerptText,
getRunDateKey,
toYamlScalar,
buildRecentlyRunExclusionSet,
parseDateValue,
parseFrontmatterCreatedAt,
@@ -72,10 +66,25 @@ import {
summarizeLockFailureReasons,
} from './scheduler-lock.mjs';
import { detectPlatform } from '../../src/utils.mjs';
import { getTorchConfigPath } from '../../src/torch-config.mjs';
import {
VALID_CADENCES,
parseArgs,
getSchedulerConfig,
} from './scheduler-config.mjs';
import {
ts,
writeLog,
exitWithSummary,
categorizeFailureMetadata,
} from './scheduler-logger.mjs';
import {
readRunState,
writeRunState,
createIdempotencyKey,
} from './scheduler-state.mjs';
const VALID_CADENCES = new Set(['daily', 'weekly']);
const ALL_EXCLUDED_REASON = 'All roster tasks currently claimed by other agents';
const FAILURE_CATEGORY = {
PROMPT_PARSE: 'prompt_parse_error',
@@ -84,65 +93,6 @@ const FAILURE_CATEGORY = {
EXECUTION: 'execution_error',
};
/**
* Parses CLI arguments into a structured options object.
* Supports both positional cadence (`daily`|`weekly`) and named flags.
* Falls back to AGENT_PLATFORM env var or auto-detected platform for platform.
*
* @param {string[]} argv - process.argv slice (args after the script name)
* @returns {{ cadence: string|null, platform: string, model: string|null }}
*/
function parseArgs(argv) {
const args = { cadence: null, platform: process.env.AGENT_PLATFORM || detectPlatform() || 'unknown', model: process.env.AGENT_MODEL || null };
for (let i = 0; i < argv.length; i += 1) {
const value = argv[i];
if (!value.startsWith('--') && !args.cadence) {
args.cadence = value;
continue;
}
if (value === '--cadence') {
args.cadence = argv[i + 1] || null;
i += 1;
continue;
}
if (value === '--platform') {
args.platform = argv[i + 1] || args.platform;
i += 1;
}
if (value === '--model') {
args.model = argv[i + 1] || args.model;
i += 1;
}
}
return args;
}
/**
* Returns an ISO 8601 timestamp string safe for use as a filename component.
* Colons are replaced with dashes and milliseconds are stripped.
* Example output: `2026-02-20T07-00-00Z`
*
* @returns {string}
*/
function ts() {
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '-');
}
/**
* Builds a metadata object that includes a standardized `failure_category` field.
* Used by writeLog() to attach structured failure classification to task logs.
*
* @param {string} failureCategory - One of FAILURE_CATEGORY values (e.g. 'lock_backend_error').
* @param {Object} [extraMetadata={}] - Additional key/value pairs to merge.
* @returns {Object}
*/
function categorizeFailureMetadata(failureCategory, extraMetadata = {}) {
return {
failure_category: failureCategory,
...extraMetadata,
};
}
/**
* Validates that a prompt file is readable and contains a valid markdown heading
* or blockquote as its first non-empty line.
@@ -308,289 +258,6 @@ function selectNextAgent({ roster, excludedSet, previousAgent, firstPrompt }) {
return null;
}
/**
* Loads and normalizes scheduler configuration for the given cadence.
* Merges torch-config.json settings with environment variable overrides.
*
* Configuration covers:
* - handoffCommand: shell command to execute the selected agent's prompt.
* - validationCommands: commands that must pass before lock:complete (default: npm run lint).
* - lockRetry: maxRetries, backoffMs, jitterMs for lock acquisition retries.
* - lockHealthPreflight: whether to probe relay health before lock acquisition.
* - lockFailurePolicy: strictLock, degradedLockRetryWindowMs, maxDeferrals.
* - memoryPolicy: mode (required|optional), retrieve/store commands, markers, artifacts.
*
* Environment variable overrides (all optional):
* SCHEDULER_LOCK_MAX_RETRIES, SCHEDULER_LOCK_BACKOFF_MS, SCHEDULER_LOCK_JITTER_MS,
* SCHEDULER_LOCK_HEALTH_PREFLIGHT, SCHEDULER_SKIP_LOCK_HEALTH_PREFLIGHT,
* SCHEDULER_STRICT_LOCK, SCHEDULER_DEGRADED_LOCK_RETRY_WINDOW_MS, SCHEDULER_MAX_DEFERRALS
*
* @param {string} cadence - 'daily'|'weekly'
* @param {{ isInteractive: boolean }} options
* @returns {Promise<Object>} Normalized scheduler config object.
*/
async function getSchedulerConfig(cadence, { isInteractive }) {
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 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';
const lockRetryRaw = scheduler.lockRetry || {};
const maxRetries = parseNonNegativeInt(
process.env.SCHEDULER_LOCK_MAX_RETRIES,
parseNonNegativeInt(lockRetryRaw.maxRetries, 2),
);
const backoffMs = parseNonNegativeInt(
process.env.SCHEDULER_LOCK_BACKOFF_MS,
parseNonNegativeInt(lockRetryRaw.backoffMs, 250),
);
const jitterMs = parseNonNegativeInt(
process.env.SCHEDULER_LOCK_JITTER_MS,
parseNonNegativeInt(lockRetryRaw.jitterMs, 75),
);
const lockHealthPreflightFromConfig = parseBooleanFlag(scheduler.lockHealthPreflight, false);
const lockHealthPreflightEnabled = parseBooleanFlag(
process.env.SCHEDULER_LOCK_HEALTH_PREFLIGHT,
lockHealthPreflightFromConfig,
);
const lockHealthPreflightSkip = parseBooleanFlag(process.env.SCHEDULER_SKIP_LOCK_HEALTH_PREFLIGHT, false);
const strictLock = parseBooleanFlag(
process.env.SCHEDULER_STRICT_LOCK,
parseBooleanFlag(scheduler.strict_lock, true),
);
const degradedLockRetryWindowMs = parseNonNegativeInt(
process.env.SCHEDULER_DEGRADED_LOCK_RETRY_WINDOW_MS,
parseNonNegativeInt(scheduler.degraded_lock_retry_window, 3600000),
);
const maxDeferrals = parseNonNegativeInt(
process.env.SCHEDULER_MAX_DEFERRALS,
parseNonNegativeInt(scheduler.max_deferrals, 3),
);
return {
firstPrompt: scheduler.firstPromptByCadence?.[cadence] || null,
handoffCommand,
missingHandoffCommandForMode,
validationCommands: Array.isArray(scheduler.validationCommandsByCadence?.[cadence])
? scheduler.validationCommandsByCadence[cadence].filter((cmd) => typeof cmd === 'string' && cmd.trim())
: ['npm run lint'],
lockRetry: {
maxRetries,
backoffMs,
jitterMs,
},
lockHealthPreflight: {
enabled: lockHealthPreflightEnabled,
skip: lockHealthPreflightSkip,
},
lockFailurePolicy: {
strictLock,
degradedLockRetryWindowMs,
maxDeferrals,
},
memoryPolicy: {
mode,
retrieveCommand: typeof memoryPolicyRaw.retrieveCommand === 'string' && memoryPolicyRaw.retrieveCommand.trim()
? resolveNodeCommand(memoryPolicyRaw.retrieveCommand.trim())
: null,
storeCommand: typeof memoryPolicyRaw.storeCommand === 'string' && memoryPolicyRaw.storeCommand.trim()
? resolveNodeCommand(memoryPolicyRaw.storeCommand.trim())
: null,
retrieveSuccessMarkers: normalizeStringList(memoryPolicyRaw.retrieveSuccessMarkers, ['MEMORY_RETRIEVED']),
storeSuccessMarkers: normalizeStringList(memoryPolicyRaw.storeSuccessMarkers, ['MEMORY_STORED']),
retrieveArtifacts: normalizeStringList(memoryPolicyRaw.retrieveArtifacts),
storeArtifacts: normalizeStringList(memoryPolicyRaw.storeArtifacts),
},
};
}
/**
* Reads persisted scheduler run-state for the current UTC day.
* Run-state is used to track lock deferral metadata across multiple invocations
* within the same day (e.g., retry window tracking for non-strict lock failures).
*
* If the state file is missing, unreadable, or belongs to a previous day, returns
* a fresh default state. State is keyed by `run_date` (YYYY-MM-DD UTC).
*
* @param {string} cadence - 'daily'|'weekly'
* @returns {Promise<{ statePath: string, state: { run_date: string, lock_deferral: Object|null } }>}
*/
async function readRunState(cadence) {
const statePath = path.resolve(process.cwd(), 'task-logs', cadence, '.scheduler-run-state.json');
const fallback = { run_date: getRunDateKey(), lock_deferral: null };
const raw = await readJson(statePath, fallback);
if (!raw || typeof raw !== 'object') {
return { statePath, state: fallback };
}
if (raw.run_date !== getRunDateKey()) {
return { statePath, state: fallback };
}
return {
statePath,
state: {
run_date: raw.run_date,
lock_deferral: raw.lock_deferral && typeof raw.lock_deferral === 'object' ? raw.lock_deferral : null,
},
};
}
/**
* Persists scheduler run-state to disk (JSON file).
* Creates parent directories if they do not exist.
*
* @param {string} statePath - Absolute path to the state file.
* @param {Object} state - State object to serialize.
* @returns {Promise<void>}
*/
async function writeRunState(statePath, state) {
await fs.mkdir(path.dirname(statePath), { recursive: true });
await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
/**
* Generates a unique idempotency key for a deferred lock attempt.
* The key is stable across retries for the same agent/cadence/date combination
* by being stored in run-state and reused on subsequent invocations.
*
* @param {{ cadence: string, selectedAgent: string, runDate: string }} params
* @returns {string}
*/
function createIdempotencyKey({ cadence, selectedAgent, runDate }) {
return `${cadence}:${selectedAgent}:${runDate}:${randomUUID()}`;
}
/**
* Writes a task log file to `task-logs/<cadence>/` with YAML frontmatter.
* File naming: `<ts>__<agent>__<status>.md`
*
* The frontmatter includes cadence, agent, status, reason, created_at, platform,
* and any additional metadata fields. The file body repeats key fields as bullets
* for human readability.
*
* These files are read by getLatestFile(), cmdCheck(), and the dashboard.
*
* @param {{ cadence: string, agent: string, status: string, reason: string,
* detail?: string, platform?: string, metadata?: Object }} params
* @returns {Promise<string>} The filename (not full path) of the written log.
*/
async function writeLog({ cadence, agent, status, reason, detail, platform, metadata = {} }) {
const logDir = path.resolve(process.cwd(), 'task-logs', cadence);
await fs.mkdir(logDir, { recursive: true });
const file = `${ts()}__${agent}__${status}.md`;
const mergedMetadata = {
platform: platform || process.env.AGENT_PLATFORM || 'unknown',
...metadata,
};
const body = [
'---',
`cadence: ${cadence}`,
`agent: ${agent}`,
`status: ${status}`,
`reason: ${toYamlScalar(reason)}`,
detail ? `detail: ${toYamlScalar(detail)}` : null,
`created_at: ${new Date().toISOString()}`,
`timestamp: ${new Date().toISOString()}`,
...Object.entries(mergedMetadata)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `${key}: ${toYamlScalar(value)}`),
'---',
'',
`# Scheduler ${status}`,
'',
`- reason: ${reason}`,
detail ? `- detail: ${detail}` : null,
...Object.entries(mergedMetadata)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `- ${key}: ${value}`),
'',
].filter(Boolean).join('\n');
await fs.writeFile(path.join(logDir, file), body, 'utf8');
return file;
}
/**
* Prints a human-readable run summary to stdout.
* Includes status, agent, prompt path, platform, reason, and the contents of
* the memory update file (up to 2000 characters, truncated if longer).
*
* @param {{ status: string, agent: string, promptPath: string|null, reason: string,
* detail?: string, memoryFile?: string, platform: string }} params
* @returns {Promise<void>}
*/
async function printRunSummary({ status, agent, promptPath, reason, detail, memoryFile, platform }) {
let learnings = 'No learnings recorded.';
if (memoryFile) {
try {
const content = await fs.readFile(memoryFile, 'utf8');
if (content.trim()) {
learnings = content.trim();
if (learnings.length > 2000) {
learnings = learnings.slice(0, 2000) + '\n... (truncated)';
}
}
} catch {
// ignore read errors
}
}
process.stdout.write('\n================================================================================\n');
process.stdout.write('Scheduler Run Summary\n');
process.stdout.write('================================================================================\n');
process.stdout.write(`Status: ${status}\n`);
process.stdout.write(`Agent: ${agent}\n`);
process.stdout.write(`Prompt: ${promptPath || '(none)'}\n`);
process.stdout.write(`Platform: ${platform}\n`);
process.stdout.write(`Reason: ${reason}\n`);
if (detail) {
process.stdout.write(`Detail: ${detail}\n`);
}
process.stdout.write('\nLearnings / Discoveries:\n');
process.stdout.write(`${learnings}\n`);
process.stdout.write('================================================================================\n\n');
}
/**
* Prints the run summary then terminates the process with the given exit code.
* All normal and failure exit paths in main() go through this function to ensure
* a summary is always printed before exit.
*
* @param {number} code - Exit code (0 = success, 1 = general failure, 2 = backend error).
* @param {Object|null} summaryData - Data passed to printRunSummary, or null to skip.
* @returns {Promise<never>}
*/
async function exitWithSummary(code, summaryData) {
if (summaryData) {
await printRunSummary(summaryData);
}
process.exit(code);
}
async function main() {
const { cadence, platform, model } = parseArgs(process.argv.slice(2));
if (!VALID_CADENCES.has(cadence)) {
+164
View File
@@ -0,0 +1,164 @@
import fsSync from 'node:fs';
import path from 'node:path';
import {
readJson,
normalizeStringList,
parseNonNegativeInt,
parseBooleanFlag,
} from './scheduler-utils.mjs';
import { detectPlatform } from '../../src/utils.mjs';
import { getTorchConfigPath } from '../../src/torch-config.mjs';
export const VALID_CADENCES = new Set(['daily', 'weekly']);
/**
* Parses CLI arguments into a structured options object.
* Supports both positional cadence (`daily`|`weekly`) and named flags.
* Falls back to AGENT_PLATFORM env var or auto-detected platform for platform.
*
* @param {string[]} argv - process.argv slice (args after the script name)
* @returns {{ cadence: string|null, platform: string, model: string|null }}
*/
export function parseArgs(argv) {
const args = { cadence: null, platform: process.env.AGENT_PLATFORM || detectPlatform() || 'unknown', model: process.env.AGENT_MODEL || null };
for (let i = 0; i < argv.length; i += 1) {
const value = argv[i];
if (!value.startsWith('--') && !args.cadence) {
args.cadence = value;
continue;
}
if (value === '--cadence') {
args.cadence = argv[i + 1] || null;
i += 1;
continue;
}
if (value === '--platform') {
args.platform = argv[i + 1] || args.platform;
i += 1;
}
if (value === '--model') {
args.model = argv[i + 1] || args.model;
i += 1;
}
}
return args;
}
/**
* Loads and normalizes scheduler configuration for the given cadence.
* Merges torch-config.json settings with environment variable overrides.
*
* Configuration covers:
* - handoffCommand: shell command to execute the selected agent's prompt.
* - validationCommands: commands that must pass before lock:complete (default: npm run lint).
* - lockRetry: maxRetries, backoffMs, jitterMs for lock acquisition retries.
* - lockHealthPreflight: whether to probe relay health before lock acquisition.
* - lockFailurePolicy: strictLock, degradedLockRetryWindowMs, maxDeferrals.
* - memoryPolicy: mode (required|optional), retrieve/store commands, markers, artifacts.
*
* Environment variable overrides (all optional):
* SCHEDULER_LOCK_MAX_RETRIES, SCHEDULER_LOCK_BACKOFF_MS, SCHEDULER_LOCK_JITTER_MS,
* SCHEDULER_LOCK_HEALTH_PREFLIGHT, SCHEDULER_SKIP_LOCK_HEALTH_PREFLIGHT,
* SCHEDULER_STRICT_LOCK, SCHEDULER_DEGRADED_LOCK_RETRY_WINDOW_MS, SCHEDULER_MAX_DEFERRALS
*
* @param {string} cadence - 'daily'|'weekly'
* @param {{ isInteractive: boolean }} options
* @returns {Promise<Object>} Normalized scheduler config object.
*/
export async function getSchedulerConfig(cadence, { isInteractive }) {
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 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';
const lockRetryRaw = scheduler.lockRetry || {};
const maxRetries = parseNonNegativeInt(
process.env.SCHEDULER_LOCK_MAX_RETRIES,
parseNonNegativeInt(lockRetryRaw.maxRetries, 2),
);
const backoffMs = parseNonNegativeInt(
process.env.SCHEDULER_LOCK_BACKOFF_MS,
parseNonNegativeInt(lockRetryRaw.backoffMs, 250),
);
const jitterMs = parseNonNegativeInt(
process.env.SCHEDULER_LOCK_JITTER_MS,
parseNonNegativeInt(lockRetryRaw.jitterMs, 75),
);
const lockHealthPreflightFromConfig = parseBooleanFlag(scheduler.lockHealthPreflight, false);
const lockHealthPreflightEnabled = parseBooleanFlag(
process.env.SCHEDULER_LOCK_HEALTH_PREFLIGHT,
lockHealthPreflightFromConfig,
);
const lockHealthPreflightSkip = parseBooleanFlag(process.env.SCHEDULER_SKIP_LOCK_HEALTH_PREFLIGHT, false);
const strictLock = parseBooleanFlag(
process.env.SCHEDULER_STRICT_LOCK,
parseBooleanFlag(scheduler.strict_lock, true),
);
const degradedLockRetryWindowMs = parseNonNegativeInt(
process.env.SCHEDULER_DEGRADED_LOCK_RETRY_WINDOW_MS,
parseNonNegativeInt(scheduler.degraded_lock_retry_window, 3600000),
);
const maxDeferrals = parseNonNegativeInt(
process.env.SCHEDULER_MAX_DEFERRALS,
parseNonNegativeInt(scheduler.max_deferrals, 3),
);
return {
firstPrompt: scheduler.firstPromptByCadence?.[cadence] || null,
handoffCommand,
missingHandoffCommandForMode,
validationCommands: Array.isArray(scheduler.validationCommandsByCadence?.[cadence])
? scheduler.validationCommandsByCadence[cadence].filter((cmd) => typeof cmd === 'string' && cmd.trim())
: ['npm run lint'],
lockRetry: {
maxRetries,
backoffMs,
jitterMs,
},
lockHealthPreflight: {
enabled: lockHealthPreflightEnabled,
skip: lockHealthPreflightSkip,
},
lockFailurePolicy: {
strictLock,
degradedLockRetryWindowMs,
maxDeferrals,
},
memoryPolicy: {
mode,
retrieveCommand: typeof memoryPolicyRaw.retrieveCommand === 'string' && memoryPolicyRaw.retrieveCommand.trim()
? resolveNodeCommand(memoryPolicyRaw.retrieveCommand.trim())
: null,
storeCommand: typeof memoryPolicyRaw.storeCommand === 'string' && memoryPolicyRaw.storeCommand.trim()
? resolveNodeCommand(memoryPolicyRaw.storeCommand.trim())
: null,
retrieveSuccessMarkers: normalizeStringList(memoryPolicyRaw.retrieveSuccessMarkers, ['MEMORY_RETRIEVED']),
storeSuccessMarkers: normalizeStringList(memoryPolicyRaw.storeSuccessMarkers, ['MEMORY_STORED']),
retrieveArtifacts: normalizeStringList(memoryPolicyRaw.retrieveArtifacts),
storeArtifacts: normalizeStringList(memoryPolicyRaw.storeArtifacts),
},
};
}
+136
View File
@@ -0,0 +1,136 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { toYamlScalar } from './scheduler-utils.mjs';
/**
* Returns an ISO 8601 timestamp string safe for use as a filename component.
* Colons are replaced with dashes and milliseconds are stripped.
* Example output: `2026-02-20T07-00-00Z`
*
* @returns {string}
*/
export function ts() {
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '-');
}
/**
* Builds a metadata object that includes a standardized `failure_category` field.
* Used by writeLog() to attach structured failure classification to task logs.
*
* @param {string} failureCategory - One of FAILURE_CATEGORY values (e.g. 'lock_backend_error').
* @param {Object} [extraMetadata={}] - Additional key/value pairs to merge.
* @returns {Object}
*/
export function categorizeFailureMetadata(failureCategory, extraMetadata = {}) {
return {
failure_category: failureCategory,
...extraMetadata,
};
}
/**
* Writes a task log file to `task-logs/<cadence>/` with YAML frontmatter.
* File naming: `<ts>__<agent>__<status>.md`
*
* The frontmatter includes cadence, agent, status, reason, created_at, platform,
* and any additional metadata fields. The file body repeats key fields as bullets
* for human readability.
*
* These files are read by getLatestFile(), cmdCheck(), and the dashboard.
*
* @param {{ cadence: string, agent: string, status: string, reason: string,
* detail?: string, platform?: string, metadata?: Object }} params
* @returns {Promise<string>} The filename (not full path) of the written log.
*/
export async function writeLog({ cadence, agent, status, reason, detail, platform, metadata = {} }) {
const logDir = path.resolve(process.cwd(), 'task-logs', cadence);
await fs.mkdir(logDir, { recursive: true });
const file = `${ts()}__${agent}__${status}.md`;
const mergedMetadata = {
platform: platform || process.env.AGENT_PLATFORM || 'unknown',
...metadata,
};
const body = [
'---',
`cadence: ${cadence}`,
`agent: ${agent}`,
`status: ${status}`,
`reason: ${toYamlScalar(reason)}`,
detail ? `detail: ${toYamlScalar(detail)}` : null,
`created_at: ${new Date().toISOString()}`,
`timestamp: ${new Date().toISOString()}`,
...Object.entries(mergedMetadata)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `${key}: ${toYamlScalar(value)}`),
'---',
'',
`# Scheduler ${status}`,
'',
`- reason: ${reason}`,
detail ? `- detail: ${detail}` : null,
...Object.entries(mergedMetadata)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `- ${key}: ${value}`),
'',
].filter(Boolean).join('\n');
await fs.writeFile(path.join(logDir, file), body, 'utf8');
return file;
}
/**
* Prints a human-readable run summary to stdout.
* Includes status, agent, prompt path, platform, reason, and the contents of
* the memory update file (up to 2000 characters, truncated if longer).
*
* @param {{ status: string, agent: string, promptPath: string|null, reason: string,
* detail?: string, memoryFile?: string, platform: string }} params
* @returns {Promise<void>}
*/
export async function printRunSummary({ status, agent, promptPath, reason, detail, memoryFile, platform }) {
let learnings = 'No learnings recorded.';
if (memoryFile) {
try {
const content = await fs.readFile(memoryFile, 'utf8');
if (content.trim()) {
learnings = content.trim();
if (learnings.length > 2000) {
learnings = learnings.slice(0, 2000) + '\n... (truncated)';
}
}
} catch {
// ignore read errors
}
}
process.stdout.write('\n================================================================================\n');
process.stdout.write('Scheduler Run Summary\n');
process.stdout.write('================================================================================\n');
process.stdout.write(`Status: ${status}\n`);
process.stdout.write(`Agent: ${agent}\n`);
process.stdout.write(`Prompt: ${promptPath || '(none)'}\n`);
process.stdout.write(`Platform: ${platform}\n`);
process.stdout.write(`Reason: ${reason}\n`);
if (detail) {
process.stdout.write(`Detail: ${detail}\n`);
}
process.stdout.write('\nLearnings / Discoveries:\n');
process.stdout.write(`${learnings}\n`);
process.stdout.write('================================================================================\n\n');
}
/**
* Prints the run summary then terminates the process with the given exit code.
* All normal and failure exit paths in main() go through this function to ensure
* a summary is always printed before exit.
*
* @param {number} code - Exit code (0 = success, 1 = general failure, 2 = backend error).
* @param {Object|null} summaryData - Data passed to printRunSummary, or null to skip.
* @returns {Promise<never>}
*/
export async function exitWithSummary(code, summaryData) {
if (summaryData) {
await printRunSummary(summaryData);
}
process.exit(code);
}
+61
View File
@@ -0,0 +1,61 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { readJson, getRunDateKey } from './scheduler-utils.mjs';
/**
* Reads persisted scheduler run-state for the current UTC day.
* Run-state is used to track lock deferral metadata across multiple invocations
* within the same day (e.g., retry window tracking for non-strict lock failures).
*
* If the state file is missing, unreadable, or belongs to a previous day, returns
* a fresh default state. State is keyed by `run_date` (YYYY-MM-DD UTC).
*
* @param {string} cadence - 'daily'|'weekly'
* @returns {Promise<{ statePath: string, state: { run_date: string, lock_deferral: Object|null } }>}
*/
export async function readRunState(cadence) {
const statePath = path.resolve(process.cwd(), 'task-logs', cadence, '.scheduler-run-state.json');
const fallback = { run_date: getRunDateKey(), lock_deferral: null };
const raw = await readJson(statePath, fallback);
if (!raw || typeof raw !== 'object') {
return { statePath, state: fallback };
}
if (raw.run_date !== getRunDateKey()) {
return { statePath, state: fallback };
}
return {
statePath,
state: {
run_date: raw.run_date,
lock_deferral: raw.lock_deferral && typeof raw.lock_deferral === 'object' ? raw.lock_deferral : null,
},
};
}
/**
* Persists scheduler run-state to disk (JSON file).
* Creates parent directories if they do not exist.
*
* @param {string} statePath - Absolute path to the state file.
* @param {Object} state - State object to serialize.
* @returns {Promise<void>}
*/
export async function writeRunState(statePath, state) {
await fs.mkdir(path.dirname(statePath), { recursive: true });
await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
/**
* Generates a unique idempotency key for a deferred lock attempt.
* The key is stable across retries for the same agent/cadence/date combination
* by being stored in run-state and reused on subsequent invocations.
*
* @param {{ cadence: string, selectedAgent: string, runDate: string }} params
* @returns {string}
*/
export function createIdempotencyKey({ cadence, selectedAgent, runDate }) {
return `${cadence}:${selectedAgent}:${runDate}:${randomUUID()}`;
}
@@ -0,0 +1,36 @@
# Decompose Agent: Context
## File Selection
- **File:** `scripts/agent/run-scheduler-cycle.mjs`
- **Reason:** Largest grandfathered JS source file (1160 lines) that is suitable for structural decomposition. Other larger files are HTML or JSON.
- **Commit:** `HEAD`
## Extraction Plan
We will extract three cohesive modules from `scripts/agent/run-scheduler-cycle.mjs` to reduce its size and improve maintainability.
### 1. Scheduler Configuration (`scripts/agent/scheduler-config.mjs`)
- **Functions to extract:**
- `parseArgs`
- `getSchedulerConfig`
- `resolveNodeCommand` (internal helper within `getSchedulerConfig` - might need to be a module-level helper or kept inside)
- Constants: `VALID_CADENCES`
### 2. Scheduler Logger (`scripts/agent/scheduler-logger.mjs`)
- **Functions to extract:**
- `writeLog`
- `printRunSummary`
- `exitWithSummary`
- `categorizeFailureMetadata`
- `ts` (timestamp helper)
### 3. Scheduler State (`scripts/agent/scheduler-state.mjs`)
- **Functions to extract:**
- `readRunState`
- `writeRunState`
- `createIdempotencyKey`
## Refactoring Strategy
1. Create new module files with necessary imports (fs, path, utils).
2. Export the identified functions.
3. Update `scripts/agent/run-scheduler-cycle.mjs` to import these functions.
4. Verify behavior with linting and dry-run.
@@ -0,0 +1,21 @@
# Decisions - Decompose Agent
## File Decomposition
**File:** `scripts/agent/run-scheduler-cycle.mjs`
### Why this file?
It was the largest JavaScript source file in the repository (1160 lines) that handles complex orchestration logic. Decomposing it improves readability, testability, and maintainability of the core scheduler loop.
### What was extracted?
1. **`scheduler-config.mjs`**: Encapsulates configuration loading, argument parsing, and environment overrides.
2. **`scheduler-logger.mjs`**: Handles structured logging, failure categorization, and run summaries.
3. **`scheduler-state.mjs`**: Manages persistent run state (lock deferrals, idempotency keys).
### Results
- **Before:** 1160 lines
- **After:** 827 lines
- **Reduction:** 333 lines (> 200 lines target)
### Verification
- `npm run lint` passed.
- `node scripts/agent/run-scheduler-cycle.mjs --help` confirmed basic module wiring works.
@@ -0,0 +1,32 @@
---
agent: decompose-agent
cadence: daily
run-start: 2026-02-27T00:00:00Z
---
# Test Log - Decompose Agent
Prompt: src/prompts/daily/decompose-agent.md
## Verification Steps
1. **Linting**: Run `npm run lint` to ensure no syntax or style errors in new/modified files.
2. **Usage Check**: Run `node scripts/agent/run-scheduler-cycle.mjs --help` to verify basic import/execution flow.
## Outputs
### npm run lint
Command: `npm run lint`
Result:
```
> [email protected] lint
> eslint .
```
(Success - exit code 0)
### node scripts/agent/run-scheduler-cycle.mjs --help
Command: `node scripts/agent/run-scheduler-cycle.mjs --help`
Result:
```
Usage: node scripts/agent/run-scheduler-cycle.mjs <daily|weekly>
```
(Success - exit code 1 as expected for missing args, but confirms script loading)
+28
View File
@@ -0,0 +1,28 @@
# TODO: Decompose `scripts/agent/run-scheduler-cycle.mjs`
## Analysis
- [x] Read and analyze `scripts/agent/run-scheduler-cycle.mjs`
- [x] Identify cohesive blocks to extract
## Implementation
- [ ] Create `scripts/agent/scheduler-config.mjs`
- [ ] Extract `parseArgs`, `getSchedulerConfig` (and `resolveNodeCommand`), `VALID_CADENCES`
- [ ] Import dependencies (`readJson`, `parseNonNegativeInt`, etc.)
- [ ] Create `scripts/agent/scheduler-logger.mjs`
- [ ] Extract `writeLog`, `printRunSummary`, `exitWithSummary`, `categorizeFailureMetadata`, `ts`
- [ ] Import dependencies (`fs`, `path`, `toYamlScalar`, etc.)
- [ ] Create `scripts/agent/scheduler-state.mjs`
- [ ] Extract `readRunState`, `writeRunState`, `createIdempotencyKey`
- [ ] Import dependencies (`fs`, `path`, `randomUUID`, `readJson`)
- [ ] Refactor `scripts/agent/run-scheduler-cycle.mjs`
- [ ] Import extracted functions from new modules
- [ ] Remove extracted code
- [ ] Verify
- [ ] `npm run lint` passes
- [ ] `node scripts/agent/run-scheduler-cycle.mjs --help` (or similar check) works
- [ ] File size reduced
## Finalization
- [ ] Run `node scripts/check-file-size.mjs --report` to confirm reduction
- [ ] Document in `DECISIONS_<timestamp>.md`
- [ ] Write memory update
@@ -0,0 +1,12 @@
---
cadence: daily
agent: decompose-agent
status: completed
reason: Decomposed scripts/agent/run-scheduler-cycle.mjs (1160 -> 827 lines)
created_at: 2026-02-27T00:00:00Z
timestamp: 2026-02-27T00:00:00Z
---
# Scheduler completed
- reason: Decomposed scripts/agent/run-scheduler-cycle.mjs (1160 -> 827 lines)