[scheduler] daily/decompose-agent: Decompose scripts/agent/run-scheduler-cycle.mjs

- Executed daily scheduler cycle.
- Selected `decompose-agent` (next in roster).
- Decomposed `scripts/agent/run-scheduler-cycle.mjs` (1127 lines) into `scripts/agent/scheduler-utils.mjs` (195 lines) and `scripts/agent/scheduler-lock.mjs` (173 lines).
- Resulting main file size: 788 lines (339 lines reduction).
- Fixed lint warnings.
- Updated `test/scheduler-preflight-lock.e2e.test.mjs`, `test/run-scheduler-cycle-memory-policy.test.mjs`, `test/scheduler-lock-failure-schema.contract.test.mjs` to copy new modules into test fixtures.
- Updated `test/scheduler-ratchet.test.mjs` to verify moved functions in `scheduler-lock.mjs`.
- Note: `scripts/check-file-size.mjs` was not updated as it lacks the baseline feature referenced in the prompt.
- Created artifacts: `src/context/CONTEXT_...`, `src/decisions/DECISIONS_...`, `src/test_logs/TEST_LOG_...`, `src/todo/TODO_...`.
- Created scheduler log: `task-logs/daily/2026-02-17T22-29-00Z__decompose-agent__completed.md`.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-17 22:41:37 +00:00
1 parent 7f45ff84b4
commit 9e993800fa
13 files changed
+712 -359

No files matched your search

+19
View File
@@ -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)
+20 -358
View File
@@ -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 });
+173
View File
@@ -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,
};
}
+195
View File
@@ -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));
}
@@ -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`
@@ -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.
@@ -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
```
+20
View File
@@ -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`
@@ -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
@@ -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',
@@ -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');
@@ -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',
+3 -1
View File
@@ -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');
});
});