diff --git a/perf/decompose/raw-file-size-report.log b/perf/decompose/raw-file-size-report.log new file mode 100644 index 0000000..612b4d0 --- /dev/null +++ b/perf/decompose/raw-file-size-report.log @@ -0,0 +1,19 @@ +TORCH.md: 342 lines (excess: 42) +dashboard/index.html: 1044 lines (excess: 744) +dashboard/styles.css: 361 lines (excess: 61) +landing/index.html: 924 lines (excess: 624) +perf/constants-refactor/candidates.json: 1668 lines (excess: 1368) +scripts/agent/run-scheduler-cycle.mjs: 1127 lines (excess: 827) +scripts/agent/verify-run-artifacts.mjs: 375 lines (excess: 75) +src/lib.mjs: 685 lines (excess: 385) +src/lock-ops.mjs: 702 lines (excess: 402) +src/ops.mjs: 450 lines (excess: 150) +src/services/memory/index.js: 440 lines (excess: 140) +src/services/memory/pruner.js: 314 lines (excess: 14) +src/services/memory/scheduler.js: 318 lines (excess: 18) +src/torch-config.mjs: 312 lines (excess: 12) +test/lib.test.mjs: 418 lines (excess: 118) +test/lock-ops.test.mjs: 395 lines (excess: 95) +test/memory-formatter.test.mjs: 442 lines (excess: 142) +test/run-scheduler-cycle-memory-policy.test.mjs: 599 lines (excess: 299) +test-audit/flakiness-matrix.json: 366 lines (excess: 66) diff --git a/scripts/agent/run-scheduler-cycle.mjs b/scripts/agent/run-scheduler-cycle.mjs index 69d2ff3..5358a49 100644 --- a/scripts/agent/run-scheduler-cycle.mjs +++ b/scripts/agent/run-scheduler-cycle.mjs @@ -3,9 +3,28 @@ // implemented by this script; step 3 (policy-file read) is best-effort and non-fatal. import fs from 'node:fs/promises'; import path from 'node:path'; -import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; +import { + runCommand, + parseJsonFromOutput, + readJson, + normalizeStringList, + parseNonNegativeInt, + parseBooleanFlag, + excerptText, + getRunDateKey, + toYamlScalar, +} from './scheduler-utils.mjs'; + +import { + classifyLockBackendError, + buildLockBackendRemediation, + runLockHealthPreflight, + acquireLockWithRetry, + summarizeLockFailureReasons, +} from './scheduler-lock.mjs'; + const VALID_CADENCES = new Set(['daily', 'weekly']); const ALL_EXCLUDED_REASON = 'All roster tasks currently claimed by other agents'; const FAILURE_CATEGORY = { @@ -15,8 +34,6 @@ const FAILURE_CATEGORY = { EXECUTION: 'execution_error', }; -const LOCK_INCIDENT_LINK = 'docs/agent-handoffs/learnings/2026-02-15-relay-health-preflight-job.md'; - function parseArgs(argv) { const args = { cadence: null, platform: process.env.AGENT_PLATFORM || 'codex', model: process.env.AGENT_MODEL || null }; for (let i = 0; i < argv.length; i += 1) { @@ -46,180 +63,6 @@ function ts() { return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '-'); } -function parseJsonFromOutput(text) { - const trimmed = String(text || '').trim(); - if (!trimmed) return null; - const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - for (let i = lines.length - 1; i >= 0; i -= 1) { - const line = lines[i]; - if (!line.startsWith('{') && !line.startsWith('[')) continue; - try { - return JSON.parse(line); - } catch { - // Keep scanning from the end. - } - } - return null; -} - -function parseJsonEventsFromOutput(text) { - const events = []; - for (const line of String(text || '').split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed.startsWith('{')) continue; - try { - const parsed = JSON.parse(trimmed); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - events.push(parsed); - } - } catch { - // Ignore non-JSON lines. - } - } - return events; -} - -const ALLOWED_ENV_KEYS = new Set([ - 'PATH', 'Path', // Windows compatibility - 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TZ', - 'NODE_ENV', 'NODE_OPTIONS', 'NODE_PATH', - 'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA', 'SystemRoot', 'windir', 'ComSpec', 'PATHEXT', - 'TMPDIR', 'TEMP', 'TMP', - 'EDITOR', 'VISUAL', - // CI variables - 'CI', 'GITHUB_ACTIONS', 'GITHUB_REF', 'GITHUB_HEAD_REF', 'GITHUB_BASE_REF', 'GITHUB_EVENT_NAME', 'GITHUB_SHA', -]); - -function getSafeEnv() { - const safeEnv = {}; - for (const key in process.env) { - if (ALLOWED_ENV_KEYS.has(key) || - key.startsWith('npm_') || - key.startsWith('NOSTR_') || - key.startsWith('TORCH_') || - key.startsWith('SCHEDULER_') || - key.startsWith('AGENT_')) { - safeEnv[key] = process.env[key]; - } - } - return safeEnv; -} - -/** - * Spawns a child process with a sanitized environment by default. - * @param {string} command - * @param {string[]} args - * @param {object} options - * @param {object} [options.env] - Additional environment variables to merge. - * @param {boolean} [options.inheritProcessEnv=false] - If true, use full process.env. If false (default), use sanitized env. - */ -async function runCommand(command, args = [], options = {}) { - const baseEnv = options.inheritProcessEnv ? process.env : getSafeEnv(); - return new Promise((resolve) => { - const child = spawn(command, args, { - cwd: process.cwd(), - env: { ...baseEnv, ...(options.env || {}) }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - let stdout = ''; - let stderr = ''; - - const OUTPUT_LIMIT = 20000; // 20KB - let stdoutWritten = 0; - let stderrWritten = 0; - let stdoutTruncated = false; - let stderrTruncated = false; - - child.stdout.on('data', (chunk) => { - const text = chunk.toString(); - stdout += text; - - if (stdoutWritten < OUTPUT_LIMIT) { - const remaining = OUTPUT_LIMIT - stdoutWritten; - if (text.length <= remaining) { - process.stdout.write(text); - stdoutWritten += text.length; - } else { - process.stdout.write(text.slice(0, remaining)); - stdoutWritten += remaining; - } - } else if (!stdoutTruncated) { - process.stdout.write('\n...[stdout truncated]...\n'); - stdoutTruncated = true; - } - }); - - child.stderr.on('data', (chunk) => { - const text = chunk.toString(); - stderr += text; - - if (stderrWritten < OUTPUT_LIMIT) { - const remaining = OUTPUT_LIMIT - stderrWritten; - if (text.length <= remaining) { - process.stderr.write(text); - stderrWritten += text.length; - } else { - process.stderr.write(text.slice(0, remaining)); - stderrWritten += remaining; - } - } else if (!stderrTruncated) { - process.stderr.write('\n...[stderr truncated]...\n'); - stderrTruncated = true; - } - }); - - child.on('close', (code) => { - resolve({ code: code ?? 1, stdout, stderr }); - }); - }); -} - -async function readJson(filePath, fallback) { - try { - const raw = await fs.readFile(filePath, 'utf8'); - return JSON.parse(raw); - } catch { - return fallback; - } -} - -function normalizeStringList(value, fallback = []) { - if (!Array.isArray(value)) return fallback; - return value.filter((item) => typeof item === 'string' && item.trim()).map((item) => item.trim()); -} - -function parseNonNegativeInt(value, fallback) { - const parsed = Number.parseInt(String(value ?? ''), 10); - if (Number.isFinite(parsed) && parsed >= 0) { - return parsed; - } - return fallback; -} - -function parseBooleanFlag(value, fallback = false) { - if (value === undefined || value === null || value === '') return fallback; - const normalized = String(value).trim().toLowerCase(); - if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; - if (['0', 'false', 'no', 'off'].includes(normalized)) return false; - return fallback; -} - -function redactSensitive(text) { - if (!text) return ''; - return String(text) - .replace(/\b(BEARER\s+)[A-Za-z0-9._~+/=-]+/gi, '$1[REDACTED]') - .replace(/\b(token|api[_-]?key|secret(?:[_-]?key)?|password|passwd|authorization)\s*[:=]\s*[^\s,;]+/gi, '$1=[REDACTED]') - .replace(/\b(sk|pk|ghp|xoxb|xoxp)_[A-Za-z0-9_-]+\b/g, '[REDACTED]'); -} - -function excerptText(text, maxChars = 600) { - const clean = redactSensitive(String(text || '').trim()); - if (!clean) return ''; - if (clean.length <= maxChars) return clean; - return `${clean.slice(0, maxChars)}…`; -} - function categorizeFailureMetadata(failureCategory, extraMetadata = {}) { return { failure_category: failureCategory, @@ -227,26 +70,6 @@ function categorizeFailureMetadata(failureCategory, extraMetadata = {}) { }; } -function formatDurationMs(ms) { - if (!Number.isFinite(ms) || ms < 0) return 'unknown'; - const minutes = Math.round(ms / 60000); - return `${minutes} minute(s)`; -} - -function buildLockBackendRemediation({ cadence, retryWindowMs, maxDeferrals, incidentSignalId = null }) { - const steps = [ - 'Likely backend/relay connectivity issue during lock acquisition.', - `Retry window: ${formatDurationMs(retryWindowMs)} (max deferrals: ${maxDeferrals}).`, - `Retry command: npm run scheduler:${cadence}`, - `Run health check: npm run lock:health -- --cadence ${cadence}`, - `Review incident runbook: ${LOCK_INCIDENT_LINK}`, - ]; - if (incidentSignalId) { - steps.push(`Incident signal: ${incidentSignalId}`); - } - return `Recommended auto-remediation: ${steps.join(' ')}`; -} - async function validatePromptFile(promptPath) { let content; try { @@ -274,47 +97,6 @@ async function validatePromptFile(promptPath) { return { ok: true }; } -function classifyLockBackendError(outputText) { - const text = String(outputText || '').toLowerCase(); - if (!text.trim()) return 'unknown_backend_error'; - - const errorCategoryMatch = text.match(/error_category=([a-z0-9_]+)/); - if (errorCategoryMatch?.[1]) { - return errorCategoryMatch[1]; - } - - if ((text.includes('relay') || text.includes('query')) && text.includes('timeout')) { - return 'relay_query_timeout'; - } - - if (text.includes('publish failed to all relays') || text.includes('failed to publish to any relay')) { - return 'relay_publish_quorum_failure'; - } - - if ( - text.includes('connection refused') - || text.includes('econnrefused') - || text.includes('getaddrinfo') - || text.includes('enotfound') - || text.includes('eai_again') - || (text.includes('websocket') && text.includes('dns')) - ) { - return 'websocket_connection_refused_or_dns'; - } - - if ( - text.includes('invalid url') - || text.includes('malformed') - || text.includes('unsupported protocol') - || text.includes('must start with ws') - || text.includes('invalid relay') - ) { - return 'malformed_relay_url_config'; - } - - return 'unknown_backend_error'; -} - async function artifactExistsSince(filePath, sinceMs) { if (!filePath) return false; const resolved = path.isAbsolute(filePath) @@ -520,10 +302,6 @@ async function getSchedulerConfig(cadence, { isInteractive }) { }; } -function getRunDateKey(now = new Date()) { - return now.toISOString().slice(0, 10); -} - 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 }; @@ -554,122 +332,6 @@ function createIdempotencyKey({ cadence, selectedAgent, runDate }) { return `${cadence}:${selectedAgent}:${runDate}:${randomUUID()}`; } -async function runLockHealthPreflight({ cadence, platform }) { - const preflight = await runCommand('npm', ['run', 'lock:health', '--', '--cadence', cadence], { - env: { AGENT_PLATFORM: platform }, - }); - const payload = parseJsonFromOutput(`${preflight.stdout}\n${preflight.stderr}`) || {}; - const relayList = Array.isArray(payload.relays) - ? payload.relays.filter((relay) => typeof relay === 'string' && relay.trim()).map((relay) => relay.trim()) - : []; - const combinedOutput = `${preflight.stderr}\n${preflight.stdout}`; - const failureCategory = payload.failureCategory || classifyLockBackendError(combinedOutput); - - return { - code: preflight.code, - payload, - relayList, - failureCategory, - stderrExcerpt: excerptText(preflight.stderr), - stdoutExcerpt: excerptText(preflight.stdout), - }; -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function acquireLockWithRetry({ selectedAgent, cadence, platform, model, lockRetry, idempotencyKey }) { - const lockCommandArgs = ['run', 'lock:lock', '--', '--agent', selectedAgent, '--cadence', cadence]; - if (model) { - lockCommandArgs.push('--model', model); - } - const backoffScheduleMs = []; - let attempts = 0; - const correlationId = idempotencyKey || `${cadence}:${selectedAgent}:${getRunDateKey()}`; - - while (true) { - attempts += 1; - const result = await runCommand('npm', lockCommandArgs, { - env: { - AGENT_PLATFORM: platform, - ...(model ? { AGENT_MODEL: model } : {}), - ...(idempotencyKey ? { SCHEDULER_LOCK_IDEMPOTENCY_KEY: idempotencyKey } : {}), - SCHEDULER_LOCK_CORRELATION_ID: correlationId, - SCHEDULER_LOCK_ATTEMPT_ID: String(attempts), - }, - }); - - if (result.code !== 2) { - return { result, attempts, backoffScheduleMs, correlationId }; - } - - if (attempts > lockRetry.maxRetries) { - return { - result, - attempts, - backoffScheduleMs, - correlationId, - finalBackendCategory: classifyLockBackendError(`${result.stderr}\n${result.stdout}`), - }; - } - - const exponentialBase = lockRetry.backoffMs * (2 ** (attempts - 1)); - const jitter = lockRetry.jitterMs > 0 - ? Math.floor(Math.random() * (lockRetry.jitterMs + 1)) - : 0; - const delayMs = exponentialBase + jitter; - backoffScheduleMs.push(delayMs); - - console.log(JSON.stringify({ - event: 'scheduler.lock.retry', - attempt: attempts, - max_retries: lockRetry.maxRetries, - delay_ms: delayMs, - correlation_id: correlationId, - selected_agent: selectedAgent, - cadence, - })); - - if (delayMs > 0) { - await sleep(delayMs); - } - } -} - -function summarizeLockFailureReasons(outputText) { - const events = parseJsonEventsFromOutput(outputText); - const quorumFailure = events.findLast((entry) => entry.event === 'lock_publish_quorum_failed'); - if (quorumFailure && quorumFailure.reasonDistribution && typeof quorumFailure.reasonDistribution === 'object') { - return { - reasonDistribution: quorumFailure.reasonDistribution, - attemptId: quorumFailure.attemptId || null, - correlationId: quorumFailure.correlationId || null, - totalElapsedMs: quorumFailure.totalElapsedMs ?? null, - }; - } - - const distribution = {}; - for (const entry of events) { - if (entry.event !== 'lock_publish_failure') continue; - const reason = typeof entry.reason === 'string' && entry.reason.trim() - ? entry.reason.trim() - : 'unknown'; - distribution[reason] = (distribution[reason] || 0) + 1; - } - return { - reasonDistribution: distribution, - attemptId: null, - correlationId: null, - totalElapsedMs: null, - }; -} - -function toYamlScalar(value) { - const str = String(value ?? ''); - return `'${str.replace(/'/g, "''")}'`; -} - 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 }); diff --git a/scripts/agent/scheduler-lock.mjs b/scripts/agent/scheduler-lock.mjs new file mode 100644 index 0000000..c0ca52b --- /dev/null +++ b/scripts/agent/scheduler-lock.mjs @@ -0,0 +1,173 @@ +import { + runCommand, + parseJsonFromOutput, + parseJsonEventsFromOutput, + excerptText, + formatDurationMs, + getRunDateKey, + sleep, +} from './scheduler-utils.mjs'; + +const LOCK_INCIDENT_LINK = 'docs/agent-handoffs/learnings/2026-02-15-relay-health-preflight-job.md'; + +export function classifyLockBackendError(outputText) { + const text = String(outputText || '').toLowerCase(); + if (!text.trim()) return 'unknown_backend_error'; + + const errorCategoryMatch = text.match(/error_category=([a-z0-9_]+)/); + if (errorCategoryMatch?.[1]) { + return errorCategoryMatch[1]; + } + + if ((text.includes('relay') || text.includes('query')) && text.includes('timeout')) { + return 'relay_query_timeout'; + } + + if (text.includes('publish failed to all relays') || text.includes('failed to publish to any relay')) { + return 'relay_publish_quorum_failure'; + } + + if ( + text.includes('connection refused') + || text.includes('econnrefused') + || text.includes('getaddrinfo') + || text.includes('enotfound') + || text.includes('eai_again') + || (text.includes('websocket') && text.includes('dns')) + ) { + return 'websocket_connection_refused_or_dns'; + } + + if ( + text.includes('invalid url') + || text.includes('malformed') + || text.includes('unsupported protocol') + || text.includes('must start with ws') + || text.includes('invalid relay') + ) { + return 'malformed_relay_url_config'; + } + + return 'unknown_backend_error'; +} + +export function buildLockBackendRemediation({ cadence, retryWindowMs, maxDeferrals, incidentSignalId = null }) { + const steps = [ + 'Likely backend/relay connectivity issue during lock acquisition.', + `Retry window: ${formatDurationMs(retryWindowMs)} (max deferrals: ${maxDeferrals}).`, + `Retry command: npm run scheduler:${cadence}`, + `Run health check: npm run lock:health -- --cadence ${cadence}`, + `Review incident runbook: ${LOCK_INCIDENT_LINK}`, + ]; + if (incidentSignalId) { + steps.push(`Incident signal: ${incidentSignalId}`); + } + return `Recommended auto-remediation: ${steps.join(' ')}`; +} + +export async function runLockHealthPreflight({ cadence, platform }) { + const preflight = await runCommand('npm', ['run', 'lock:health', '--', '--cadence', cadence], { + env: { AGENT_PLATFORM: platform }, + }); + const payload = parseJsonFromOutput(`${preflight.stdout}\n${preflight.stderr}`) || {}; + const relayList = Array.isArray(payload.relays) + ? payload.relays.filter((relay) => typeof relay === 'string' && relay.trim()).map((relay) => relay.trim()) + : []; + const combinedOutput = `${preflight.stderr}\n${preflight.stdout}`; + const failureCategory = payload.failureCategory || classifyLockBackendError(combinedOutput); + + return { + code: preflight.code, + payload, + relayList, + failureCategory, + stderrExcerpt: excerptText(preflight.stderr), + stdoutExcerpt: excerptText(preflight.stdout), + }; +} + +export async function acquireLockWithRetry({ selectedAgent, cadence, platform, model, lockRetry, idempotencyKey }) { + const lockCommandArgs = ['run', 'lock:lock', '--', '--agent', selectedAgent, '--cadence', cadence]; + if (model) { + lockCommandArgs.push('--model', model); + } + const backoffScheduleMs = []; + let attempts = 0; + const correlationId = idempotencyKey || `${cadence}:${selectedAgent}:${getRunDateKey()}`; + + while (true) { + attempts += 1; + const result = await runCommand('npm', lockCommandArgs, { + env: { + AGENT_PLATFORM: platform, + ...(model ? { AGENT_MODEL: model } : {}), + ...(idempotencyKey ? { SCHEDULER_LOCK_IDEMPOTENCY_KEY: idempotencyKey } : {}), + SCHEDULER_LOCK_CORRELATION_ID: correlationId, + SCHEDULER_LOCK_ATTEMPT_ID: String(attempts), + }, + }); + + if (result.code !== 2) { + return { result, attempts, backoffScheduleMs, correlationId }; + } + + if (attempts > lockRetry.maxRetries) { + return { + result, + attempts, + backoffScheduleMs, + correlationId, + finalBackendCategory: classifyLockBackendError(`${result.stderr}\n${result.stdout}`), + }; + } + + const exponentialBase = lockRetry.backoffMs * (2 ** (attempts - 1)); + const jitter = lockRetry.jitterMs > 0 + ? Math.floor(Math.random() * (lockRetry.jitterMs + 1)) + : 0; + const delayMs = exponentialBase + jitter; + backoffScheduleMs.push(delayMs); + + console.log(JSON.stringify({ + event: 'scheduler.lock.retry', + attempt: attempts, + max_retries: lockRetry.maxRetries, + delay_ms: delayMs, + correlation_id: correlationId, + selected_agent: selectedAgent, + cadence, + })); + + if (delayMs > 0) { + await sleep(delayMs); + } + } +} + +export function summarizeLockFailureReasons(outputText) { + const events = parseJsonEventsFromOutput(outputText); + const quorumFailure = events.findLast((entry) => entry.event === 'lock_publish_quorum_failed'); + if (quorumFailure && quorumFailure.reasonDistribution && typeof quorumFailure.reasonDistribution === 'object') { + return { + reasonDistribution: quorumFailure.reasonDistribution, + attemptId: quorumFailure.attemptId || null, + correlationId: quorumFailure.correlationId || null, + totalElapsedMs: quorumFailure.totalElapsedMs ?? null, + }; + } + + const distribution = {}; + for (const entry of events) { + if (entry.event !== 'lock_publish_failure') continue; + const reason = typeof entry.reason === 'string' && entry.reason.trim() + ? entry.reason.trim() + : 'unknown'; + distribution[reason] = (distribution[reason] || 0) + 1; + } + return { + reasonDistribution: distribution, + attemptId: null, + correlationId: null, + totalElapsedMs: null, + }; +} diff --git a/scripts/agent/scheduler-utils.mjs b/scripts/agent/scheduler-utils.mjs new file mode 100644 index 0000000..5403122 --- /dev/null +++ b/scripts/agent/scheduler-utils.mjs @@ -0,0 +1,195 @@ +import fs from 'node:fs/promises'; +import { spawn } from 'node:child_process'; + +const ALLOWED_ENV_KEYS = new Set([ + 'PATH', 'Path', // Windows compatibility + 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TZ', + 'NODE_ENV', 'NODE_OPTIONS', 'NODE_PATH', + 'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA', 'SystemRoot', 'windir', 'ComSpec', 'PATHEXT', + 'TMPDIR', 'TEMP', 'TMP', + 'EDITOR', 'VISUAL', + // CI variables + 'CI', 'GITHUB_ACTIONS', 'GITHUB_REF', 'GITHUB_HEAD_REF', 'GITHUB_BASE_REF', 'GITHUB_EVENT_NAME', 'GITHUB_SHA', +]); + +export function getSafeEnv() { + const safeEnv = {}; + for (const key in process.env) { + if (ALLOWED_ENV_KEYS.has(key) || + key.startsWith('npm_') || + key.startsWith('NOSTR_') || + key.startsWith('TORCH_') || + key.startsWith('SCHEDULER_') || + key.startsWith('AGENT_')) { + safeEnv[key] = process.env[key]; + } + } + return safeEnv; +} + +/** + * Spawns a child process with a sanitized environment by default. + * @param {string} command + * @param {string[]} args + * @param {object} options + * @param {object} [options.env] - Additional environment variables to merge. + * @param {boolean} [options.inheritProcessEnv=false] - If true, use full process.env. If false (default), use sanitized env. + */ +export async function runCommand(command, args = [], options = {}) { + const baseEnv = options.inheritProcessEnv ? process.env : getSafeEnv(); + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: process.cwd(), + env: { ...baseEnv, ...(options.env || {}) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + const OUTPUT_LIMIT = 20000; // 20KB + let stdoutWritten = 0; + let stderrWritten = 0; + let stdoutTruncated = false; + let stderrTruncated = false; + + child.stdout.on('data', (chunk) => { + const text = chunk.toString(); + stdout += text; + + if (stdoutWritten < OUTPUT_LIMIT) { + const remaining = OUTPUT_LIMIT - stdoutWritten; + if (text.length <= remaining) { + process.stdout.write(text); + stdoutWritten += text.length; + } else { + process.stdout.write(text.slice(0, remaining)); + stdoutWritten += remaining; + } + } else if (!stdoutTruncated) { + process.stdout.write('\n...[stdout truncated]...\n'); + stdoutTruncated = true; + } + }); + + child.stderr.on('data', (chunk) => { + const text = chunk.toString(); + stderr += text; + + if (stderrWritten < OUTPUT_LIMIT) { + const remaining = OUTPUT_LIMIT - stderrWritten; + if (text.length <= remaining) { + process.stderr.write(text); + stderrWritten += text.length; + } else { + process.stderr.write(text.slice(0, remaining)); + stderrWritten += remaining; + } + } else if (!stderrTruncated) { + process.stderr.write('\n...[stderr truncated]...\n'); + stderrTruncated = true; + } + }); + + child.on('close', (code) => { + resolve({ code: code ?? 1, stdout, stderr }); + }); + }); +} + +export function parseJsonFromOutput(text) { + const trimmed = String(text || '').trim(); + if (!trimmed) return null; + const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i]; + if (!line.startsWith('{') && !line.startsWith('[')) continue; + try { + return JSON.parse(line); + } catch { + // Keep scanning from the end. + } + } + return null; +} + +export function parseJsonEventsFromOutput(text) { + const events = []; + for (const line of String(text || '').split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith('{')) continue; + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + events.push(parsed); + } + } catch { + // Ignore non-JSON lines. + } + } + return events; +} + +export async function readJson(filePath, fallback) { + try { + const raw = await fs.readFile(filePath, 'utf8'); + return JSON.parse(raw); + } catch { + return fallback; + } +} + +export function normalizeStringList(value, fallback = []) { + if (!Array.isArray(value)) return fallback; + return value.filter((item) => typeof item === 'string' && item.trim()).map((item) => item.trim()); +} + +export function parseNonNegativeInt(value, fallback) { + const parsed = Number.parseInt(String(value ?? ''), 10); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed; + } + return fallback; +} + +export function parseBooleanFlag(value, fallback = false) { + if (value === undefined || value === null || value === '') return fallback; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +export function redactSensitive(text) { + if (!text) return ''; + return String(text) + .replace(/\b(BEARER\s+)[A-Za-z0-9._~+/=-]+/gi, '$1[REDACTED]') + .replace(/\b(token|api[_-]?key|secret(?:[_-]?key)?|password|passwd|authorization)\s*[:=]\s*[^\s,;]+/gi, '$1=[REDACTED]') + .replace(/\b(sk|pk|ghp|xoxb|xoxp)_[A-Za-z0-9_-]+\b/g, '[REDACTED]'); +} + +export function excerptText(text, maxChars = 600) { + const clean = redactSensitive(String(text || '').trim()); + if (!clean) return ''; + if (clean.length <= maxChars) return clean; + return `${clean.slice(0, maxChars)}…`; +} + +export function formatDurationMs(ms) { + if (!Number.isFinite(ms) || ms < 0) return 'unknown'; + const minutes = Math.round(ms / 60000); + return `${minutes} minute(s)`; +} + +export function getRunDateKey(now = new Date()) { + return now.toISOString().slice(0, 10); +} + +export function toYamlScalar(value) { + const str = String(value ?? ''); + return `'${str.replace(/'/g, "''")}'`; +} + +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/context/CONTEXT_2026-02-17T22-29-00Z.md b/src/context/CONTEXT_2026-02-17T22-29-00Z.md new file mode 100644 index 0000000..b34e5fe --- /dev/null +++ b/src/context/CONTEXT_2026-02-17T22-29-00Z.md @@ -0,0 +1,34 @@ +# Context: Decompose scripts/agent/run-scheduler-cycle.mjs + +- **Chosen File**: `scripts/agent/run-scheduler-cycle.mjs` +- **Line Count**: 1127 lines +- **Reason**: Largest source file in the repository (excluding generated/vendored/test logs). No substantial refactor found in recent history (last commit was a merge). +- **Goal**: Reduce file size by extracting cohesive blocks into new modules. +- **Node Version**: v22.22.0 (as seen in `npm run lock:check:daily` output previously) +- **Extraction Plan**: + 1. Extract low-level utilities to `scripts/agent/scheduler-utils.mjs`. + 2. Extract lock-related logic to `scripts/agent/scheduler-lock.mjs`. + +## Proposed Extractions + +### 1. `scripts/agent/scheduler-utils.mjs` +This will house generic helpers used throughout the script, reducing the noise in the main logic. +- `runCommand` +- `getSafeEnv` +- `sleep` +- `parseJsonFromOutput` +- `parseJsonEventsFromOutput` +- `redactSensitive` +- `excerptText` +- `parseNonNegativeInt` +- `parseBooleanFlag` +- `readJson` +- `normalizeStringList` + +### 2. `scripts/agent/scheduler-lock.mjs` +This will encapsulate the complex locking logic. +- `acquireLockWithRetry` +- `summarizeLockFailureReasons` +- `classifyLockBackendError` +- `buildLockBackendRemediation` +- `runLockHealthPreflight` diff --git a/src/decisions/DECISIONS_2026-02-17T22-29-00Z.md b/src/decisions/DECISIONS_2026-02-17T22-29-00Z.md new file mode 100644 index 0000000..6708d47 --- /dev/null +++ b/src/decisions/DECISIONS_2026-02-17T22-29-00Z.md @@ -0,0 +1,26 @@ +# Decisions: Decompose scripts/agent/run-scheduler-cycle.mjs + +## Rationale +The `scripts/agent/run-scheduler-cycle.mjs` file had grown to 1127 lines, making it the largest source file in the repository. It contained mixed responsibilities: process management, logging, locking logic, and configuration parsing. + +We decided to extract cohesive blocks to improve maintainability and testability. + +## Extracted Modules + +### 1. `scripts/agent/scheduler-utils.mjs` +**Why:** Many utility functions (`runCommand`, `getSafeEnv`, `parseJson*`, `sleep`, etc.) were cluttering the main logic. These are generic and can be reused or at least moved out of the way. +**Changes:** Moved low-level helpers here. Exported them. + +### 2. `scripts/agent/scheduler-lock.mjs` +**Why:** The locking logic (`acquireLockWithRetry`, failure classification, remediation) is complex and distinct from the core scheduler loop. It forms a "locking service" conceptual block. +**Changes:** Moved `acquireLockWithRetry`, `classifyLockBackendError`, `buildLockBackendRemediation`, `runLockHealthPreflight`, and `summarizeLockFailureReasons` here. + +## Baseline Update +The `scripts/check-file-size.mjs` script does not currently support an `--update` flag or contain a `BASELINE` object as referenced in the agent prompt. Therefore, no code change was made to that script. However, the file size reduction was verified manually: +- Original: 1127 lines +- New: 788 lines +- Reduction: 339 lines (exceeds 200 line target). + +## Test Updates +The E2E tests (`test/scheduler-preflight-lock.e2e.test.mjs`, `test/run-scheduler-cycle-memory-policy.test.mjs`, `test/scheduler-lock-failure-schema.contract.test.mjs`) required updates to copy the new module files (`scheduler-utils.mjs`, `scheduler-lock.mjs`) into the test fixture directory. +The unit test `test/scheduler-ratchet.test.mjs` was updated to look for `classifyLockBackendError` in the new `scheduler-lock.mjs` file instead of the main script. diff --git a/src/test_logs/TEST_LOG_2026-02-17T22-29-00Z.md b/src/test_logs/TEST_LOG_2026-02-17T22-29-00Z.md new file mode 100644 index 0000000..c45679c --- /dev/null +++ b/src/test_logs/TEST_LOG_2026-02-17T22-29-00Z.md @@ -0,0 +1,202 @@ +# Test Log: Decompose scripts/agent/run-scheduler-cycle.mjs + +## 1. File Size Report +Before: 1127 lines +After: 788 lines + +## 2. Linting +Command: `npm run lint` +Output: No errors in modified files. + +## 3. Unit/Integration Tests +Command: `node --test test/scheduler-lock-failure-schema.contract.test.mjs test/run-scheduler-cycle-memory-policy.test.mjs test/scheduler-preflight-lock.e2e.test.mjs test/scheduler-ratchet.test.mjs` + +### Output +``` +TAP version 13 +# Subtest: fails required memory policy when retrieval/storage evidence is missing +ok 1 - fails required memory policy when retrieval/storage evidence is missing + --- + duration_ms: 757.539101 + type: 'test' + ... +# Subtest: accepts required memory policy when markers or artifacts are produced +ok 2 - accepts required memory policy when markers or artifacts are produced + --- + duration_ms: 762.353236 + type: 'test' + ... +# Subtest: records backend failure metadata when lock command exits with code 2 +ok 3 - records backend failure metadata when lock command exits with code 2 + --- + duration_ms: 907.42582 + type: 'test' + ... +# Subtest: defers backend failures in non-strict mode and reuses idempotency key on successful retry +ok 4 - defers backend failures in non-strict mode and reuses idempotency key on successful retry + --- + duration_ms: 515.738251 + type: 'test' + ... +# Subtest: fails after exceeding non-strict deferral budget +ok 5 - fails after exceeding non-strict deferral budget + --- + duration_ms: 169.270959 + type: 'test' + ... +# Subtest: strict lock mode fails immediately without deferral +ok 6 - strict lock mode fails immediately without deferral + --- + duration_ms: 84.263333 + type: 'test' + ... +# Subtest: skips lock health preflight by default when not enabled +ok 7 - skips lock health preflight by default when not enabled + --- + duration_ms: 351.440095 + type: 'test' + ... +# Subtest: fails scheduler with preflight metadata when lock health preflight is enabled +ok 8 - fails scheduler with preflight metadata when lock health preflight is enabled + --- + duration_ms: 122.484853 + type: 'test' + ... +# Subtest: categorizes unreadable prompt files as prompt_parse_error +ok 9 - categorizes unreadable prompt files as prompt_parse_error + --- + duration_ms: 87.191858 + type: 'test' + ... +# Subtest: categorizes invalid prompt schema as prompt_schema_error +ok 10 - categorizes invalid prompt schema as prompt_schema_error + --- + duration_ms: 87.797569 + type: 'test' + ... +# Subtest: categorizes handoff runtime failures as execution_error +ok 11 - categorizes handoff runtime failures as execution_error + --- + duration_ms: 324.626141 + type: 'test' + ... +# Subtest: retries lock acquisition for backend error and succeeds on a later attempt +ok 12 - retries lock acquisition for backend error and succeeds on a later attempt + --- + duration_ms: 367.965729 + type: 'test' + ... +# Subtest: stops retrying after configured lock backend retries are exhausted +ok 13 - stops retrying after configured lock backend retries are exhausted + --- + duration_ms: 93.241513 + type: 'test' + ... +# Subtest: does not use backend retry flow for exit code 3 lock conflicts +ok 14 - does not use backend retry flow for exit code 3 lock conflicts + --- + duration_ms: 364.90421 + type: 'test' + ... +# Subtest: scheduler lock backend failure artifact matches required frontmatter schema +ok 15 - scheduler lock backend failure artifact matches required frontmatter schema + --- + duration_ms: 99.881359 + type: 'test' + ... +# Subtest: schema contract catches missing required lock-backend field +ok 16 - schema contract catches missing required lock-backend field + --- + duration_ms: 1.852952 + type: 'test' + ... +# Subtest: schema contract catches misnamed lock-backend key +ok 17 - schema contract catches misnamed lock-backend key + --- + duration_ms: 0.591693 + type: 'test' + ... +# Subtest: lock preflight e2e: successful lock writes completed status snapshot +ok 18 - lock preflight e2e: successful lock writes completed status snapshot + --- + duration_ms: 375.694252 + type: 'test' + ... +# Subtest: lock preflight e2e: exit code 2 quorum failure persists failed backend status and prompt-not-started marker +ok 19 - lock preflight e2e: exit code 2 quorum failure persists failed backend status and prompt-not-started marker + --- + duration_ms: 88.901459 + type: 'test' + ... +# Subtest: lock preflight e2e: non-lock failure exits failed without prompt parse/schema classification +ok 20 - lock preflight e2e: non-lock failure exits failed without prompt parse/schema classification + --- + duration_ms: 84.551615 + type: 'test' + ... +# Subtest: Scheduler Ratchet Logic (Log Checking) + # Subtest: excludes agents completed today (daily) + ok 1 - excludes agents completed today (daily) + --- + duration_ms: 2.674164 + type: 'test' + ... + # Subtest: excludes agents completed this week (weekly) + ok 2 - excludes agents completed this week (weekly) + --- + duration_ms: 0.457543 + type: 'test' + ... + # Subtest: combines locked and completed agents in exclusion list + ok 3 - combines locked and completed agents in exclusion list + --- + duration_ms: 0.529591 + type: 'test' + ... + # Subtest: ignores logs when --ignore-logs is set + ok 4 - ignores logs when --ignore-logs is set + --- + duration_ms: 0.486543 + type: 'test' + ... + # Subtest: handles missing log directory gracefully + ok 5 - handles missing log directory gracefully + --- + duration_ms: 0.365618 + type: 'test' + ... + 1..5 +ok 21 - Scheduler Ratchet Logic (Log Checking) + --- + duration_ms: 5.944697 + type: 'suite' + ... +# Subtest: Scheduler cycle ordering guarantees + # Subtest: keeps optional preflight before lock-acquire and preserves handoff/artifact/validation/complete ordering + ok 1 - keeps optional preflight before lock-acquire and preserves handoff/artifact/validation/complete ordering + --- + duration_ms: 11.091114 + type: 'test' + ... + # Subtest: includes lock backend failure metadata and classifier checkpoints + ok 2 - includes lock backend failure metadata and classifier checkpoints + --- + duration_ms: 2.007641 + type: 'test' + ... + 1..2 +ok 22 - Scheduler cycle ordering guarantees + --- + duration_ms: 13.942915 + type: 'suite' + ... +1..22 +# tests 27 +# suites 2 +# pass 27 +# fail 0 +# cancelled 0 +# skipped 0 +# todo 0 +# duration_ms 5077.329481 +``` diff --git a/src/todo/TODO_2026-02-17T22-29-00Z.md b/src/todo/TODO_2026-02-17T22-29-00Z.md new file mode 100644 index 0000000..4b93168 --- /dev/null +++ b/src/todo/TODO_2026-02-17T22-29-00Z.md @@ -0,0 +1,20 @@ +# Todo: Decompose scripts/agent/run-scheduler-cycle.mjs + +- [ ] Extract `scripts/agent/scheduler-utils.mjs` + - [ ] `runCommand` + - [ ] `getSafeEnv` + - [ ] `sleep` + - [ ] `parseJsonFromOutput` + - [ ] `parseJsonEventsFromOutput` + - [ ] `redactSensitive` + - [ ] `excerptText` + - [ ] `parseNonNegativeInt` + - [ ] `parseBooleanFlag` + - [ ] `readJson` + - [ ] `normalizeStringList` +- [ ] Extract `scripts/agent/scheduler-lock.mjs` + - [ ] `acquireLockWithRetry` + - [ ] `summarizeLockFailureReasons` + - [ ] `classifyLockBackendError` + - [ ] `buildLockBackendRemediation` + - [ ] `runLockHealthPreflight` diff --git a/task-logs/daily/2026-02-17T22-29-00Z__decompose-agent__completed.md b/task-logs/daily/2026-02-17T22-29-00Z__decompose-agent__completed.md new file mode 100644 index 0000000..4939a48 --- /dev/null +++ b/task-logs/daily/2026-02-17T22-29-00Z__decompose-agent__completed.md @@ -0,0 +1,14 @@ +--- +cadence: daily +agent: decompose-agent +status: completed +reason: 'Decomposed scripts/agent/run-scheduler-cycle.mjs' +created_at: 2026-02-17T22:29:00.000Z +timestamp: 2026-02-17T22:29:00.000Z +platform: jules +--- + +# Scheduler completed + +- reason: Decomposed scripts/agent/run-scheduler-cycle.mjs +- platform: jules diff --git a/test/run-scheduler-cycle-memory-policy.test.mjs b/test/run-scheduler-cycle-memory-policy.test.mjs index bf1b383..bfd6b63 100644 --- a/test/run-scheduler-cycle-memory-policy.test.mjs +++ b/test/run-scheduler-cycle-memory-policy.test.mjs @@ -71,6 +71,8 @@ async function setupFixture({ await fs.mkdir(binDir, { recursive: true }); 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.writeFile( path.join(scriptsDir, 'verify-run-artifacts.mjs'), '#!/usr/bin/env node\nprocess.exit(0);\n', diff --git a/test/scheduler-lock-failure-schema.contract.test.mjs b/test/scheduler-lock-failure-schema.contract.test.mjs index 1c63e60..c4f1139 100644 --- a/test/scheduler-lock-failure-schema.contract.test.mjs +++ b/test/scheduler-lock-failure-schema.contract.test.mjs @@ -93,6 +93,8 @@ async function setupFixture({ lockShellBody }) { await fs.mkdir(binDir, { recursive: true }); 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.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'); await fs.writeFile(path.join(root, 'src', 'prompts', 'daily', 'agent-a.md'), '# agent-a\n', 'utf8'); diff --git a/test/scheduler-preflight-lock.e2e.test.mjs b/test/scheduler-preflight-lock.e2e.test.mjs index 21d7333..b1f0bef 100644 --- a/test/scheduler-preflight-lock.e2e.test.mjs +++ b/test/scheduler-preflight-lock.e2e.test.mjs @@ -42,6 +42,8 @@ async function setupFixture({ lockShellBody = ':', schedulerPolicy = {} }) { await fs.mkdir(binDir, { recursive: true }); 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.writeFile( path.join(scriptsDir, 'verify-run-artifacts.mjs'), '#!/usr/bin/env node\nprocess.exit(0);\n', diff --git a/test/scheduler-ratchet.test.mjs b/test/scheduler-ratchet.test.mjs index 00fe540..9ee2012 100644 --- a/test/scheduler-ratchet.test.mjs +++ b/test/scheduler-ratchet.test.mjs @@ -150,9 +150,9 @@ describe('Scheduler cycle ordering guarantees', () => { it('includes lock backend failure metadata and classifier checkpoints', async () => { const { readFile } = await import('node:fs/promises'); const source = await readFile(new URL('../scripts/agent/run-scheduler-cycle.mjs', import.meta.url), 'utf8'); + const lockSource = await readFile(new URL('../scripts/agent/scheduler-lock.mjs', import.meta.url), 'utf8'); const requiredSnippets = [ - 'function classifyLockBackendError(outputText)', "backend_category: backendCategory", "lock_command: lockCommand", "lock_stderr_excerpt: stderrExcerpt || '(empty)'", @@ -163,6 +163,8 @@ describe('Scheduler cycle ordering guarantees', () => { for (const snippet of requiredSnippets) { assert.ok(source.includes(snippet), `Expected scheduler snippet not found: ${snippet}`); } + + assert.ok(lockSource.includes('function classifyLockBackendError(outputText)'), 'Expected classifyLockBackendError in scheduler-lock.mjs'); }); });