Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 257 additions & 1 deletion lib/mini-ralph/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ const DEFAULTS = {
// toward the streak because their signal is already surfaced via the
// `Recent Loop Signals` feedback block.
stallThreshold: 3,
// Opt-in continuation after a BLOCKED_HANDOFF only when the handoff note has
// explicit evidence for a safe, bounded resolution class.
autoResolveHandoffs: true,
autoResolveHandoffMaxPerRun: 6,
};

/**
Expand Down Expand Up @@ -80,6 +84,170 @@ function _iterationIsStalled(iterationSignals) {
return true;
}

function _resolveAutoResolveHandoffConfig(options, existingState) {
const enabled = options.autoResolveHandoffs === true;
const maxPerRun =
Number.isInteger(options.autoResolveHandoffMaxPerRun) &&
options.autoResolveHandoffMaxPerRun > 0
? options.autoResolveHandoffMaxPerRun
: DEFAULTS.autoResolveHandoffMaxPerRun;
const previous =
existingState &&
existingState.autoResolveHandoffs &&
typeof existingState.autoResolveHandoffs === 'object'
? existingState.autoResolveHandoffs
: {};
const previousAttempts =
previous.attempts && typeof previous.attempts === 'object'
? previous.attempts
: {};
const previousTotal = Number.isInteger(previous.totalAttempts)
? previous.totalAttempts
: 0;

return {
enabled,
maxPerRun,
state: {
enabled,
maxPerRun,
totalAttempts: previousTotal,
attempts: Object.assign({}, previousAttempts),
lastDecision: previous.lastDecision || null,
},
};
}

function _handoffHasFocusedVerifierEvidence(note) {
if (!note) return false;
const text = String(note);
const mentionsFocusedVerifier =
/\bfocused\b[\s\S]{0,500}\b(verifier|command|test|spec|vitest)\b/i.test(text) ||
/\b(verifier|command|test|spec|vitest)\b[\s\S]{0,500}\bfocused\b/i.test(text);
const saysFocusedPasses =
/\b(passes?|passed|exits?\s+0|exit(?:ed)?\s+0|green)\b/i.test(text);
const saysBroadFails =
/\b(broad|full|required|suite|repo-wide)\b[\s\S]{0,500}\b(fails?|failed|red|non[-\s]?zero)\b/i.test(text) ||
/\b(fails?|failed|red|non[-\s]?zero)\b[\s\S]{0,500}\b(broad|full|required|suite|repo-wide)\b/i.test(text);
const saysFailuresAreUnrelated =
/\b(unrelated|pre[-\s]?existing|out[-\s]?of[-\s]?scope|known failures?|not introduced|baseline)\b/i.test(text);

return mentionsFocusedVerifier && saysFocusedPasses && saysBroadFails && saysFailuresAreUnrelated;
}

function _classifyAutoResolvableHandoff(blockerNote, baselineGateConflict) {
if (_handoffHasFocusedVerifierEvidence(blockerNote)) {
return {
className: 'verifier_narrowing',
summary: 'focused verifier passes while the broad verifier fails on unrelated/pre-existing failures',
allowedFiles: [],
};
}

if (
baselineGateConflict &&
baselineGateConflict.mode === 'authorized_cleanup' &&
baselineGateConflict.budgetUsed !== true &&
Array.isArray(baselineGateConflict.allowedFiles) &&
baselineGateConflict.allowedFiles.length > 0
) {
return {
className: 'authorized_cleanup',
summary: 'task text explicitly authorizes one cleanup attempt for named files',
allowedFiles: baselineGateConflict.allowedFiles.slice(),
};
}

return null;
}

function _autoResolveHandoffBudgetKey(currentTaskMeta, className) {
const taskId =
currentTaskMeta && currentTaskMeta.number
? currentTaskMeta.number
: currentTaskMeta && currentTaskMeta.description
? currentTaskMeta.description
: 'unknown-task';
return `${taskId}:${className || 'unknown'}`;
}

function _decideAutoResolveHandoff(config, blockerNote, currentTaskMeta, baselineGateConflict) {
const disabledDecision = { allowed: false, reason: 'disabled', className: '', budgetKey: '' };
if (!config || config.enabled !== true) return disabledDecision;

const classification = _classifyAutoResolvableHandoff(blockerNote, baselineGateConflict);
if (!classification) {
return {
allowed: false,
reason: 'ambiguous_or_unsupported_handoff',
className: '',
budgetKey: '',
};
}

const budgetKey = _autoResolveHandoffBudgetKey(currentTaskMeta, classification.className);
const totalAttempts = Number.isInteger(config.state && config.state.totalAttempts)
? config.state.totalAttempts
: 0;
const maxPerRun = Number.isInteger(config.maxPerRun)
? config.maxPerRun
: DEFAULTS.autoResolveHandoffMaxPerRun;
const attempts = config.state && config.state.attempts ? config.state.attempts : {};

if (totalAttempts >= maxPerRun) {
return Object.assign({}, classification, {
allowed: false,
reason: 'global_budget_exhausted',
budgetKey,
});
}

if (attempts[budgetKey]) {
return Object.assign({}, classification, {
allowed: false,
reason: 'task_class_budget_exhausted',
budgetKey,
});
}

return Object.assign({}, classification, {
allowed: true,
reason: 'authorized',
budgetKey,
});
}

function _consumeAutoResolveHandoffBudget(config, decision, iteration) {
if (!config || !config.state || !decision || decision.allowed !== true || !decision.budgetKey) {
return null;
}

const attempts = Object.assign({}, config.state.attempts || {});
attempts[decision.budgetKey] = {
className: decision.className,
iteration,
attemptedAt: new Date().toISOString(),
};

const totalAttempts = (Number.isInteger(config.state.totalAttempts)
? config.state.totalAttempts
: 0) + 1;

config.state = Object.assign({}, config.state, {
totalAttempts,
attempts,
lastDecision: {
className: decision.className,
reason: decision.reason,
budgetKey: decision.budgetKey,
iteration,
allowedFiles: decision.allowedFiles || [],
},
});

return config.state;
}

function _isFailedIteration(result) {
if (!result || typeof result !== 'object') return false;
if (result.signal !== null && result.signal !== undefined && result.signal !== '') {
Expand Down Expand Up @@ -462,6 +630,7 @@ async function run(opts) {
const resumeIteration = _resolveStartIteration(existingState, options);
const priorRunWasBlockedHandoff =
existingState && existingState.exitReason === 'blocked_handoff';
const autoResolveHandoffs = _resolveAutoResolveHandoffConfig(options, existingState);

if (options.verbose && resumeIteration > 1) {
process.stderr.write(
Expand Down Expand Up @@ -512,6 +681,7 @@ async function run(opts) {
stoppedAt: null,
exitReason: null,
pendingDirtyPaths,
autoResolveHandoffs: autoResolveHandoffs.state,
});
stateInitialized = true;

Expand Down Expand Up @@ -597,6 +767,7 @@ async function run(opts) {
fullHistory,
);
const baselineGateFeedback = _formatBaselineGateFeedback(baselineGateConflict);
const autoResolveHandoffFeedback = _buildAutoResolveHandoffFeedback(recentHistory);

// Inject any pending context
const pendingContext = context.consume(ralphDir);
Expand All @@ -612,6 +783,10 @@ async function run(opts) {
promptSections.push(`## Recent Loop Signals\n\n${iterationFeedback}`);
}

if (autoResolveHandoffFeedback) {
promptSections.push(`## Auto-Resolve Handoff\n\n${autoResolveHandoffFeedback}`);
}

if (lessonsSection) {
promptSections.push(lessonsSection);
}
Expand Down Expand Up @@ -705,6 +880,24 @@ async function run(opts) {
const blockerNote = hasBlockedHandoff
? _extractBlockerNote(outputText, blockedHandoffPromise)
: '';
const autoResolveHandoffDecision = hasBlockedHandoff
? _decideAutoResolveHandoff(
autoResolveHandoffs,
blockerNote,
currentTaskMeta,
baselineGateConflict,
)
: null;
if (autoResolveHandoffDecision && autoResolveHandoffDecision.allowed) {
const nextAutoResolveState = _consumeAutoResolveHandoffBudget(
autoResolveHandoffs,
autoResolveHandoffDecision,
iterationCount,
);
if (nextAutoResolveState) {
state.update(ralphDir, { autoResolveHandoffs: nextAutoResolveState });
}
}
const tasksAfter = options.tasksMode && options.tasksFile
? tasks.parseTasks(options.tasksFile)
: [];
Expand Down Expand Up @@ -787,6 +980,15 @@ async function run(opts) {
signal: result.signal || '',
failureStage: result.failureStage || '',
completedTasks: completedTasks.map((task) => task.fullDescription || task.description),
...(autoResolveHandoffDecision
? {
autoResolveHandoffAttempted: autoResolveHandoffDecision.allowed === true,
autoResolveHandoffClass: autoResolveHandoffDecision.className || '',
autoResolveHandoffReason: autoResolveHandoffDecision.reason || '',
autoResolveHandoffBudgetKey: autoResolveHandoffDecision.budgetKey || '',
autoResolveHandoffAllowedFiles: autoResolveHandoffDecision.allowedFiles || [],
}
: {}),
commitAttempted: commitResult.attempted,
commitCreated: commitResult.committed,
commitAnomaly: commitResult.anomaly ? commitResult.anomaly.message : '',
Expand Down Expand Up @@ -894,9 +1096,21 @@ async function run(opts) {
reporter.note(
handoffPath
? `agent emitted ${blockedHandoffPromise}; blocker note saved to ${handoffPath}.`
: `agent emitted ${blockedHandoffPromise}; halting (HANDOFF.md write failed; see stderr).`,
: `agent emitted ${blockedHandoffPromise}; HANDOFF.md write failed (see stderr).`,
'warn'
);
if (autoResolveHandoffDecision && autoResolveHandoffDecision.allowed) {
reporter.note(
`auto-resolve handoffs: continuing once for ${autoResolveHandoffDecision.className} (${autoResolveHandoffDecision.budgetKey}).`,
'warn'
);
if (options.verbose) {
process.stderr.write(
`[mini-ralph] auto-resolve handoff consumed budget key ${autoResolveHandoffDecision.budgetKey}; continuing.\n`
);
}
continue;
}
if (options.verbose) {
process.stderr.write(
`[mini-ralph] ${blockedHandoffPromise} detected at iteration ${iterationCount}; halting.\n`
Expand Down Expand Up @@ -1780,6 +1994,42 @@ function _buildIterationFeedback(recentHistory, errorEntries, blockerArtifacts)
return sections.join('\n');
}

function _buildAutoResolveHandoffFeedback(recentHistory) {
if (!Array.isArray(recentHistory) || recentHistory.length === 0) return '';

const entry = recentHistory
.slice()
.reverse()
.find((item) => item && item.autoResolveHandoffAttempted === true);

if (!entry) return '';

const className = entry.autoResolveHandoffClass || 'unknown';
const lines = [
`The previous iteration emitted BLOCKED_HANDOFF, but auto-resolution is enabled and spent its bounded attempt for ${className}.`,
'You have exactly one continuation attempt for this task/blocker class. Do not broaden task scope, do not repair unrelated snapshots or UI behavior, and do not keep retrying if the evidence does not hold.',
];

if (className === 'verifier_narrowing') {
lines.push(
'Allowed action: if the handoff explicitly names a focused verifier that passes and a broad verifier that fails only on unrelated/pre-existing failures, update only the current task verifier from the broad command to that focused command, run the focused command once, and complete the task only if it passes. If the focused command is absent, ambiguous, or fails, emit BLOCKED_HANDOFF instead of retrying.'
);
} else if (className === 'authorized_cleanup') {
const files = Array.isArray(entry.autoResolveHandoffAllowedFiles)
? entry.autoResolveHandoffAllowedFiles.filter(Boolean)
: [];
lines.push(
`Allowed action: make one cleanup attempt only in the task-authorized file list${files.length > 0 ? ` (${files.join(', ')})` : ''}. If the gate still fails, emit BLOCKED_HANDOFF instead of continuing.`
);
} else {
lines.push(
'Allowed action: continue only if the blocker evidence remains explicit and within the runner-approved safe class; otherwise emit BLOCKED_HANDOFF.'
);
}

return lines.join('\n');
}

function _buildBaselineGateFeedback(ralphDir, tasksFile, currentTaskMeta, recentHistory) {
return _formatBaselineGateFeedback(
_analyzeBaselineGateConflict(ralphDir, tasksFile, currentTaskMeta, recentHistory)
Expand Down Expand Up @@ -2392,6 +2642,12 @@ module.exports = {
_formatAutoCommitMessage,
_truncateSubjectSummary,
_buildIterationFeedback,
_buildAutoResolveHandoffFeedback,
_resolveAutoResolveHandoffConfig,
_handoffHasFocusedVerifierEvidence,
_classifyAutoResolvableHandoff,
_decideAutoResolveHandoff,
_consumeAutoResolveHandoffBudget,
_buildBaselineGateFeedback,
_analyzeBaselineGateConflict,
_formatBaselineGateFeedback,
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "spec-and-loop",
"version": "3.3.2",
"version": "3.3.3",
"description": "OpenSpec + Ralph Loop integration for iterative development with opencode",
"main": "index.js",
"bin": {
Expand Down
Loading
Loading