spec-and-loop 2.1.2 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OPENSPEC-RALPH-BP.md +564 -0
- package/QUICKSTART.md +32 -10
- package/README.md +70 -6
- package/lib/mini-ralph/history.js +37 -0
- package/lib/mini-ralph/invoker.js +108 -7
- package/lib/mini-ralph/lessons.js +93 -0
- package/lib/mini-ralph/progress.js +404 -0
- package/lib/mini-ralph/prompt.js +78 -6
- package/lib/mini-ralph/runner.js +592 -33
- package/lib/mini-ralph/state.js +57 -5
- package/lib/mini-ralph/tasks.js +5 -10
- package/package.json +4 -4
- package/scripts/mini-ralph-cli.js +18 -2
- package/scripts/ralph-run.sh +402 -79
package/lib/mini-ralph/runner.js
CHANGED
|
@@ -20,6 +20,8 @@ const tasks = require('./tasks');
|
|
|
20
20
|
const prompt = require('./prompt');
|
|
21
21
|
const invoker = require('./invoker');
|
|
22
22
|
const errors = require('./errors');
|
|
23
|
+
const progress = require('./progress');
|
|
24
|
+
const lessons = require('./lessons');
|
|
23
25
|
|
|
24
26
|
const DEFAULTS = {
|
|
25
27
|
minIterations: 1,
|
|
@@ -29,8 +31,41 @@ const DEFAULTS = {
|
|
|
29
31
|
tasksMode: false,
|
|
30
32
|
noCommit: false,
|
|
31
33
|
verbose: false,
|
|
34
|
+
// Emits a per-iteration runtime status line (task #, ok/fail/stall badge,
|
|
35
|
+
// duration, rolling counters, cumulative + average time) to stderr. Enabled
|
|
36
|
+
// by default because "what is the loop doing right now?" is the single most
|
|
37
|
+
// common operator question. Pass `quiet: true` to suppress.
|
|
38
|
+
quiet: false,
|
|
39
|
+
// Stall detector: break the loop after N *consecutive* iterations that
|
|
40
|
+
// succeeded but produced no progress (no promise, no completed tasks, no
|
|
41
|
+
// files changed). 0 disables the detector. Failed iterations do not count
|
|
42
|
+
// toward the streak because their signal is already surfaced via the
|
|
43
|
+
// `Recent Loop Signals` feedback block.
|
|
44
|
+
stallThreshold: 3,
|
|
32
45
|
};
|
|
33
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Determine whether an iteration made any forward progress.
|
|
49
|
+
*
|
|
50
|
+
* An iteration is considered productive if any of the following are true:
|
|
51
|
+
* - OpenCode emitted the task or completion promise
|
|
52
|
+
* - One or more tasks transitioned to "completed" during the iteration
|
|
53
|
+
* - At least one repo-tracked file was observed to have changed
|
|
54
|
+
* - The iteration failed outright (its signal is handled separately)
|
|
55
|
+
*
|
|
56
|
+
* @param {object} iterationSignals
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
function _iterationIsStalled(iterationSignals) {
|
|
60
|
+
if (!iterationSignals) return false;
|
|
61
|
+
if (iterationSignals.iterationFailed) return false;
|
|
62
|
+
if (iterationSignals.hasCompletion) return false;
|
|
63
|
+
if (iterationSignals.hasTask) return false;
|
|
64
|
+
if (iterationSignals.completedTasksCount > 0) return false;
|
|
65
|
+
if (iterationSignals.filesChangedCount > 0) return false;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
34
69
|
function _isFailedIteration(result) {
|
|
35
70
|
if (!result || typeof result !== 'object') return false;
|
|
36
71
|
if (result.signal !== null && result.signal !== undefined && result.signal !== '') {
|
|
@@ -43,6 +78,20 @@ function _wasSuccessfulIteration(result) {
|
|
|
43
78
|
return !_isFailedIteration(result);
|
|
44
79
|
}
|
|
45
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Measure the size of a text string for telemetry purposes.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} str
|
|
85
|
+
* @returns {{ bytes: number, chars: number, tokens: number }}
|
|
86
|
+
*/
|
|
87
|
+
function _measureText(str) {
|
|
88
|
+
if (typeof str !== 'string') str = '';
|
|
89
|
+
const bytes = Buffer.byteLength(str, 'utf8');
|
|
90
|
+
const chars = str.length;
|
|
91
|
+
const tokens = Math.round(chars / 4);
|
|
92
|
+
return { bytes, chars, tokens };
|
|
93
|
+
}
|
|
94
|
+
|
|
46
95
|
function _failureStageForError(err) {
|
|
47
96
|
if (!err || typeof err !== 'object') {
|
|
48
97
|
return 'invoke_contract';
|
|
@@ -96,6 +145,13 @@ function _appendFatalIterationFailure(ralphDir, entry) {
|
|
|
96
145
|
commitAnomaly: '',
|
|
97
146
|
commitAnomalyType: '',
|
|
98
147
|
protectedArtifacts: [],
|
|
148
|
+
promptBytes: entry.promptBytes || 0,
|
|
149
|
+
promptChars: entry.promptChars || 0,
|
|
150
|
+
promptTokens: entry.promptTokens || 0,
|
|
151
|
+
responseBytes: entry.responseBytes || 0,
|
|
152
|
+
responseChars: entry.responseChars || 0,
|
|
153
|
+
responseTokens: entry.responseTokens || 0,
|
|
154
|
+
truncated: entry.truncated || false,
|
|
99
155
|
});
|
|
100
156
|
}
|
|
101
157
|
|
|
@@ -119,11 +175,24 @@ async function run(opts) {
|
|
|
119
175
|
const minIterations = options.minIterations;
|
|
120
176
|
const completionPromise = options.completionPromise;
|
|
121
177
|
const taskPromise = options.taskPromise;
|
|
178
|
+
const stallThreshold =
|
|
179
|
+
typeof options.stallThreshold === 'number' && options.stallThreshold >= 0
|
|
180
|
+
? Math.floor(options.stallThreshold)
|
|
181
|
+
: DEFAULTS.stallThreshold;
|
|
182
|
+
|
|
183
|
+
const reporter = options.reporter || progress.create({
|
|
184
|
+
enabled: !options.quiet,
|
|
185
|
+
maxIterations,
|
|
186
|
+
});
|
|
122
187
|
|
|
123
188
|
let stateInitialized = false;
|
|
124
189
|
let iterationCount = 0;
|
|
125
190
|
let completed = false;
|
|
126
191
|
let exitReason = 'max_iterations';
|
|
192
|
+
// Consecutive iterations that succeeded but produced no progress signal.
|
|
193
|
+
// Reset whenever any progress is detected (or when the iteration failed, so
|
|
194
|
+
// transient infra errors don't trip the stall detector).
|
|
195
|
+
let stallStreak = 0;
|
|
127
196
|
|
|
128
197
|
try {
|
|
129
198
|
|
|
@@ -139,7 +208,25 @@ async function run(opts) {
|
|
|
139
208
|
);
|
|
140
209
|
}
|
|
141
210
|
|
|
211
|
+
reporter.runStarted({
|
|
212
|
+
tasksMode: Boolean(options.tasksMode),
|
|
213
|
+
model: options.model || '',
|
|
214
|
+
resumed: resumeIteration > 1 ? resumeIteration - 1 : null,
|
|
215
|
+
});
|
|
216
|
+
|
|
142
217
|
// Initialize state file for this run, preserving history count if resuming.
|
|
218
|
+
//
|
|
219
|
+
// `startedAt` semantics: this field marks the first time *this change* was
|
|
220
|
+
// put through a Ralph loop. On a resume we must preserve the original
|
|
221
|
+
// timestamp, not overwrite it with the current time -- previously, every
|
|
222
|
+
// resume reset `startedAt` and the status dashboard lost the true wall-
|
|
223
|
+
// clock duration. `resumedAt` tracks the most recent resume.
|
|
224
|
+
const nowIso = new Date().toISOString();
|
|
225
|
+
const preservedStartedAt =
|
|
226
|
+
resumeIteration > 1 && existingState && existingState.startedAt
|
|
227
|
+
? existingState.startedAt
|
|
228
|
+
: nowIso;
|
|
229
|
+
|
|
143
230
|
state.init(ralphDir, {
|
|
144
231
|
active: true,
|
|
145
232
|
iteration: resumeIteration,
|
|
@@ -153,8 +240,8 @@ async function run(opts) {
|
|
|
153
240
|
promptTemplate: options.promptTemplate || null,
|
|
154
241
|
noCommit: options.noCommit,
|
|
155
242
|
model: options.model || '',
|
|
156
|
-
startedAt:
|
|
157
|
-
resumedAt: resumeIteration > 1 ?
|
|
243
|
+
startedAt: preservedStartedAt,
|
|
244
|
+
resumedAt: resumeIteration > 1 ? nowIso : null,
|
|
158
245
|
completedAt: null,
|
|
159
246
|
stoppedAt: null,
|
|
160
247
|
exitReason: null,
|
|
@@ -181,8 +268,17 @@ async function run(opts) {
|
|
|
181
268
|
? tasks.parseTasks(options.tasksFile)
|
|
182
269
|
: [];
|
|
183
270
|
const currentTask = _getCurrentTaskDescription(tasksBefore);
|
|
271
|
+
const currentTaskMeta = _getCurrentTaskMeta(tasksBefore);
|
|
272
|
+
|
|
273
|
+
reporter.iterationStarted({
|
|
274
|
+
iteration: iterationCount,
|
|
275
|
+
taskNumber: currentTaskMeta.number,
|
|
276
|
+
taskDescription: currentTaskMeta.description,
|
|
277
|
+
});
|
|
184
278
|
|
|
185
279
|
let result;
|
|
280
|
+
let promptSize = null;
|
|
281
|
+
let responseSize = { bytes: 0, chars: 0, tokens: 0 };
|
|
186
282
|
|
|
187
283
|
try {
|
|
188
284
|
// Build the prompt for this iteration
|
|
@@ -194,23 +290,42 @@ async function run(opts) {
|
|
|
194
290
|
throw err;
|
|
195
291
|
}
|
|
196
292
|
|
|
293
|
+
// Emit 3 iterations of Recent Loop Signals — the `_failureFingerprint`
|
|
294
|
+
// dedup collapses identical entries into a single "same failure as
|
|
295
|
+
// iteration N" line, so the 3-entry window is sufficient to surface
|
|
296
|
+
// recurring patterns without bloating the prompt.
|
|
197
297
|
const errorEntries = errors.readEntries(ralphDir, 3);
|
|
198
298
|
const iterationFeedback = _buildIterationFeedback(history.recent(ralphDir, 3), errorEntries);
|
|
199
299
|
|
|
200
300
|
// Inject any pending context
|
|
201
301
|
const pendingContext = context.consume(ralphDir);
|
|
302
|
+
lessons.rotate(ralphDir, 100);
|
|
303
|
+
const lessonsSection = lessons.inject(ralphDir, { limit: 15 });
|
|
202
304
|
const promptSections = [renderedPrompt];
|
|
203
305
|
|
|
204
306
|
if (iterationFeedback) {
|
|
205
307
|
promptSections.push(`## Recent Loop Signals\n\n${iterationFeedback}`);
|
|
206
308
|
}
|
|
207
309
|
|
|
310
|
+
if (lessonsSection) {
|
|
311
|
+
promptSections.push(lessonsSection);
|
|
312
|
+
}
|
|
313
|
+
|
|
208
314
|
if (pendingContext) {
|
|
209
315
|
promptSections.push(`## Injected Context\n\n${pendingContext}`);
|
|
210
316
|
}
|
|
211
317
|
|
|
212
318
|
const finalPrompt = promptSections.join('\n\n');
|
|
213
319
|
|
|
320
|
+
// Measure and report prompt size
|
|
321
|
+
promptSize = _measureText(finalPrompt);
|
|
322
|
+
reporter.iterationPromptReady({
|
|
323
|
+
iteration: iterationCount,
|
|
324
|
+
promptBytes: promptSize.bytes,
|
|
325
|
+
promptChars: promptSize.chars,
|
|
326
|
+
promptTokens: promptSize.tokens,
|
|
327
|
+
});
|
|
328
|
+
|
|
214
329
|
// Invoke OpenCode
|
|
215
330
|
try {
|
|
216
331
|
result = await invoker.invoke({
|
|
@@ -220,20 +335,52 @@ async function run(opts) {
|
|
|
220
335
|
verbose: options.verbose,
|
|
221
336
|
ralphDir,
|
|
222
337
|
});
|
|
338
|
+
// Measure and report response size
|
|
339
|
+
const rawOutput = result.rawOutput || result.output || result.stdout || '';
|
|
340
|
+
responseSize = _measureText(rawOutput);
|
|
341
|
+
reporter.iterationResponseReceived({
|
|
342
|
+
iteration: iterationCount,
|
|
343
|
+
responseBytes: responseSize.bytes,
|
|
344
|
+
responseChars: responseSize.chars,
|
|
345
|
+
responseTokens: responseSize.tokens,
|
|
346
|
+
truncated: result.truncated || false,
|
|
347
|
+
});
|
|
223
348
|
} catch (err) {
|
|
224
349
|
err.failureStage = err.failureStage || 'invoke_start';
|
|
225
350
|
throw err;
|
|
226
351
|
}
|
|
227
352
|
} catch (err) {
|
|
353
|
+
const fatalDuration = Date.now() - iterStart;
|
|
228
354
|
_appendFatalIterationFailure(ralphDir, {
|
|
229
355
|
iteration: iterationCount,
|
|
230
356
|
task: currentTask,
|
|
231
|
-
duration:
|
|
357
|
+
duration: fatalDuration,
|
|
232
358
|
exitCode: null,
|
|
233
359
|
signal: '',
|
|
234
360
|
failureStage: _failureStageForError(err),
|
|
235
361
|
stderr: _errorText(err),
|
|
236
362
|
stdout: '',
|
|
363
|
+
promptBytes: promptSize ? promptSize.bytes : 0,
|
|
364
|
+
promptChars: promptSize ? promptSize.chars : 0,
|
|
365
|
+
promptTokens: promptSize ? promptSize.tokens : 0,
|
|
366
|
+
responseBytes: 0,
|
|
367
|
+
responseChars: 0,
|
|
368
|
+
responseTokens: 0,
|
|
369
|
+
truncated: false,
|
|
370
|
+
});
|
|
371
|
+
reporter.iterationFinished({
|
|
372
|
+
iteration: iterationCount,
|
|
373
|
+
durationMs: fatalDuration,
|
|
374
|
+
outcome: 'failure',
|
|
375
|
+
committed: false,
|
|
376
|
+
hasCompletion: false,
|
|
377
|
+
hasTask: false,
|
|
378
|
+
completedTasksCount: 0,
|
|
379
|
+
filesChangedCount: 0,
|
|
380
|
+
stallStreak,
|
|
381
|
+
failureReason: `${_failureStageForError(err)}: ${_firstNonEmptyLine(_errorText(err), 120)}`,
|
|
382
|
+
taskNumber: currentTaskMeta.number,
|
|
383
|
+
taskDescription: currentTaskMeta.description,
|
|
237
384
|
});
|
|
238
385
|
throw err;
|
|
239
386
|
}
|
|
@@ -277,6 +424,7 @@ async function run(opts) {
|
|
|
277
424
|
filesToStage: _buildAutoCommitAllowlist(result.filesChanged, completedTasks, options.tasksFile),
|
|
278
425
|
tasksFile: options.tasksFile,
|
|
279
426
|
verbose: options.verbose,
|
|
427
|
+
reporter,
|
|
280
428
|
});
|
|
281
429
|
}
|
|
282
430
|
|
|
@@ -298,6 +446,59 @@ async function run(opts) {
|
|
|
298
446
|
commitAnomaly: commitResult.anomaly ? commitResult.anomaly.message : '',
|
|
299
447
|
commitAnomalyType: commitResult.anomaly ? commitResult.anomaly.type : '',
|
|
300
448
|
protectedArtifacts: commitResult.anomaly ? commitResult.anomaly.protectedArtifacts || [] : [],
|
|
449
|
+
...(commitResult.anomaly && commitResult.anomaly.ignoredPaths && commitResult.anomaly.ignoredPaths.length > 0
|
|
450
|
+
? { ignoredPaths: commitResult.anomaly.ignoredPaths }
|
|
451
|
+
: {}),
|
|
452
|
+
promptBytes: promptSize ? promptSize.bytes : 0,
|
|
453
|
+
promptChars: promptSize ? promptSize.chars : 0,
|
|
454
|
+
promptTokens: promptSize ? promptSize.tokens : 0,
|
|
455
|
+
responseBytes: responseSize.bytes,
|
|
456
|
+
responseChars: responseSize.chars,
|
|
457
|
+
responseTokens: responseSize.tokens,
|
|
458
|
+
truncated: result.truncated || false,
|
|
459
|
+
// Pass through watchdog failure fields when the invoker returns them (task 3.1).
|
|
460
|
+
...(result.failureReason !== undefined ? { failureReason: result.failureReason } : {}),
|
|
461
|
+
...(result.idleMs !== undefined ? { idleMs: result.idleMs } : {}),
|
|
462
|
+
...(result.lastStdoutBytes !== undefined ? { lastStdoutBytes: result.lastStdoutBytes } : {}),
|
|
463
|
+
...(result.lastStderrBytes !== undefined ? { lastStderrBytes: result.lastStderrBytes } : {}),
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// Stall detection is computed *before* the progress event so the
|
|
467
|
+
// reporter can show the live streak alongside the badge. We still
|
|
468
|
+
// enforce the stall halt after the event so the operator sees the
|
|
469
|
+
// final (stalled) iteration line before the "halting" note.
|
|
470
|
+
const iterationFailed = _isFailedIteration(result);
|
|
471
|
+
const stalledThisIteration = _iterationIsStalled({
|
|
472
|
+
iterationFailed,
|
|
473
|
+
hasCompletion,
|
|
474
|
+
hasTask,
|
|
475
|
+
completedTasksCount: completedTasks.length,
|
|
476
|
+
filesChangedCount: Array.isArray(result.filesChanged) ? result.filesChanged.length : 0,
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
if (stalledThisIteration) {
|
|
480
|
+
stallStreak++;
|
|
481
|
+
} else {
|
|
482
|
+
stallStreak = 0;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
reporter.iterationFinished({
|
|
486
|
+
iteration: iterationCount,
|
|
487
|
+
durationMs: duration,
|
|
488
|
+
outcome: iterationFailed
|
|
489
|
+
? 'failure'
|
|
490
|
+
: stalledThisIteration
|
|
491
|
+
? 'stalled'
|
|
492
|
+
: 'success',
|
|
493
|
+
committed: commitResult.committed === true,
|
|
494
|
+
hasCompletion,
|
|
495
|
+
hasTask,
|
|
496
|
+
completedTasksCount: completedTasks.length,
|
|
497
|
+
filesChangedCount: Array.isArray(result.filesChanged) ? result.filesChanged.length : 0,
|
|
498
|
+
stallStreak,
|
|
499
|
+
failureReason: iterationFailed ? _summarizeFailure(result) : '',
|
|
500
|
+
taskNumber: currentTaskMeta.number,
|
|
501
|
+
taskDescription: currentTaskMeta.description,
|
|
301
502
|
});
|
|
302
503
|
|
|
303
504
|
// Check completion condition (must also satisfy minIterations)
|
|
@@ -307,6 +508,20 @@ async function run(opts) {
|
|
|
307
508
|
break;
|
|
308
509
|
}
|
|
309
510
|
|
|
511
|
+
if (stallThreshold > 0 && stallStreak >= stallThreshold) {
|
|
512
|
+
reporter.note(
|
|
513
|
+
`stall detector: ${stallStreak} consecutive no-op iteration(s); halting.`,
|
|
514
|
+
'warn'
|
|
515
|
+
);
|
|
516
|
+
if (options.verbose) {
|
|
517
|
+
process.stderr.write(
|
|
518
|
+
`[mini-ralph] stall detector: ${stallStreak} consecutive no-op iteration(s); halting.\n`
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
exitReason = 'stalled';
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
|
|
310
525
|
// In tasks mode, task promise just continues the loop
|
|
311
526
|
if (options.tasksMode && hasTask) {
|
|
312
527
|
// Continue to next iteration
|
|
@@ -315,6 +530,8 @@ async function run(opts) {
|
|
|
315
530
|
}
|
|
316
531
|
} catch (err) {
|
|
317
532
|
exitReason = 'fatal_error';
|
|
533
|
+
reporter.note(`fatal error: ${_firstNonEmptyLine(err && err.message, 120) || 'unknown'}`, 'error');
|
|
534
|
+
reporter.runFinished({ completed: false, exitReason, iterations: iterationCount });
|
|
318
535
|
throw err;
|
|
319
536
|
}
|
|
320
537
|
|
|
@@ -322,6 +539,8 @@ async function run(opts) {
|
|
|
322
539
|
_cleanupCompletedErrors(ralphDir, options.verbose);
|
|
323
540
|
}
|
|
324
541
|
|
|
542
|
+
reporter.runFinished({ completed, exitReason, iterations: iterationCount });
|
|
543
|
+
|
|
325
544
|
return { completed, iterations: iterationCount, exitReason };
|
|
326
545
|
} finally {
|
|
327
546
|
if (stateInitialized) {
|
|
@@ -377,8 +596,8 @@ function _validateOptions(options) {
|
|
|
377
596
|
if (!options.ralphDir) {
|
|
378
597
|
throw new Error('mini-ralph runner: options.ralphDir is required');
|
|
379
598
|
}
|
|
380
|
-
if (!options.promptFile && !options.promptText) {
|
|
381
|
-
throw new Error('mini-ralph runner:
|
|
599
|
+
if (!options.promptFile && !options.promptText && !options.promptTemplate) {
|
|
600
|
+
throw new Error('mini-ralph runner: at least one of options.promptFile, options.promptText, or options.promptTemplate is required');
|
|
382
601
|
}
|
|
383
602
|
if (options.promptFile && options.promptText) {
|
|
384
603
|
throw new Error('mini-ralph runner: provide either options.promptFile or options.promptText, not both');
|
|
@@ -394,6 +613,33 @@ function _validateOptions(options) {
|
|
|
394
613
|
}
|
|
395
614
|
}
|
|
396
615
|
|
|
616
|
+
/**
|
|
617
|
+
* Format the loud direct stderr block for auto-commit ignore-filter events.
|
|
618
|
+
* Emitted via process.stderr.write (bypassing reporter dedup/buffering) on
|
|
619
|
+
* every iteration where paths_ignored_filtered or all_paths_ignored fires.
|
|
620
|
+
* (task 5.1 — surface-autocommit-ignore-warning-and-watchdog)
|
|
621
|
+
*
|
|
622
|
+
* @param {number} iteration
|
|
623
|
+
* @param {{ type: string, ignoredPaths: string[] }} anomaly
|
|
624
|
+
* @returns {string}
|
|
625
|
+
*/
|
|
626
|
+
function _formatAutoCommitIgnoreBlock(iteration, anomaly) {
|
|
627
|
+
const SEP = '================================================================================\n';
|
|
628
|
+
const pathLines = (anomaly.ignoredPaths || []).map(p => ` - ${p}`).join('\n');
|
|
629
|
+
return (
|
|
630
|
+
SEP +
|
|
631
|
+
`⚠ AUTO-COMMIT IGNORE FILTER FIRED (iteration ${iteration}, type: ${anomaly.type})\n` +
|
|
632
|
+
`Paths filtered because .gitignore matches:\n` +
|
|
633
|
+
pathLines + '\n' +
|
|
634
|
+
`Consequence: these paths are NOT in the latest commit.\n` +
|
|
635
|
+
`Remediation (pick one):\n` +
|
|
636
|
+
` 1. git add -f <path> # one-time unblock, if you want it tracked\n` +
|
|
637
|
+
` 2. edit .gitignore # narrow or remove the matching rule\n` +
|
|
638
|
+
` 3. pass --no-auto-commit on the ralph-run invocation\n` +
|
|
639
|
+
SEP
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
397
643
|
/**
|
|
398
644
|
* Auto-commit changed files after a successful iteration.
|
|
399
645
|
* Silently skips if git is unavailable, there is nothing to commit, or the
|
|
@@ -406,7 +652,7 @@ function _validateOptions(options) {
|
|
|
406
652
|
* @param {boolean} [opts.verbose]
|
|
407
653
|
*/
|
|
408
654
|
function _autoCommit(iteration, opts = {}) {
|
|
409
|
-
const { completedTasks = [], filesToStage = [], tasksFile = null, verbose = false } = opts;
|
|
655
|
+
const { completedTasks = [], filesToStage = [], tasksFile = null, verbose = false, reporter = null } = opts;
|
|
410
656
|
const message = _formatAutoCommitMessage(iteration, completedTasks);
|
|
411
657
|
|
|
412
658
|
if (!message) {
|
|
@@ -437,8 +683,55 @@ function _autoCommit(iteration, opts = {}) {
|
|
|
437
683
|
return { attempted: true, committed: false, anomaly };
|
|
438
684
|
}
|
|
439
685
|
|
|
686
|
+
const { kept: keptPaths, dropped: droppedPaths } = _filterGitignored(filesToStage, process.cwd());
|
|
687
|
+
|
|
688
|
+
if (droppedPaths.length > 0) {
|
|
689
|
+
const pathWord = droppedPaths.length === 1 ? 'path' : 'paths';
|
|
690
|
+
const allIgnored = keptPaths.length === 0;
|
|
691
|
+
const warnLines = allIgnored
|
|
692
|
+
? [
|
|
693
|
+
`auto-commit iter ${iteration} skipped: all ${droppedPaths.length} ${pathWord} are gitignored`,
|
|
694
|
+
...droppedPaths.map(p => ` - ${p}`),
|
|
695
|
+
' hint: `git add -f <path>` once, or adjust .gitignore',
|
|
696
|
+
].join('\n')
|
|
697
|
+
: [
|
|
698
|
+
`auto-commit iter ${iteration}: filtered ${droppedPaths.length} gitignored ${pathWord}, committing ${keptPaths.length} ${keptPaths.length === 1 ? 'other' : 'others'}`,
|
|
699
|
+
...droppedPaths.map(p => ` - ${p}`),
|
|
700
|
+
].join('\n');
|
|
701
|
+
if (reporter) {
|
|
702
|
+
reporter.note(warnLines, 'error');
|
|
703
|
+
} else {
|
|
704
|
+
const fallbackMsg = allIgnored
|
|
705
|
+
? `Auto-commit skipped: all paths are gitignored: ${droppedPaths.join(', ')}`
|
|
706
|
+
: `Auto-commit filtered gitignored paths: ${droppedPaths.join(', ')}`;
|
|
707
|
+
process.stderr.write(`[mini-ralph] warning: ${fallbackMsg}\n`);
|
|
708
|
+
}
|
|
709
|
+
if (allIgnored) {
|
|
710
|
+
const anomaly = {
|
|
711
|
+
type: 'all_paths_ignored',
|
|
712
|
+
message: `Auto-commit skipped: all paths are gitignored: ${droppedPaths.join(', ')}`,
|
|
713
|
+
ignoredPaths: droppedPaths,
|
|
714
|
+
};
|
|
715
|
+
// task 5.1: emit loud direct stderr block, bypassing reporter dedup/buffering
|
|
716
|
+
process.stderr.write(_formatAutoCommitIgnoreBlock(iteration, anomaly));
|
|
717
|
+
return {
|
|
718
|
+
attempted: true,
|
|
719
|
+
committed: false,
|
|
720
|
+
anomaly,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const stagePaths = droppedPaths.length > 0 ? keptPaths : filesToStage;
|
|
726
|
+
|
|
440
727
|
try {
|
|
441
|
-
|
|
728
|
+
// Use `git add -A -- <paths>` (not plain `git add -- <paths>`) so deletions
|
|
729
|
+
// and renames are staged alongside modifications/additions. Tasks that call
|
|
730
|
+
// `git rm` via a shell tool leave the path absent from the working tree but
|
|
731
|
+
// still present in `git status --porcelain`, which means the plain form
|
|
732
|
+
// would error with `fatal: pathspec did not match`. Scoping to the per-path
|
|
733
|
+
// allowlist preserves the protected-artifact guarantee.
|
|
734
|
+
childProcess.execFileSync('git', ['add', '-A', '--', ...stagePaths], {
|
|
442
735
|
stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
|
|
443
736
|
encoding: 'utf8',
|
|
444
737
|
});
|
|
@@ -469,6 +762,20 @@ function _autoCommit(iteration, opts = {}) {
|
|
|
469
762
|
if (verbose) {
|
|
470
763
|
process.stderr.write(`[mini-ralph] auto-committed: ${message}\n`);
|
|
471
764
|
}
|
|
765
|
+
if (droppedPaths.length > 0) {
|
|
766
|
+
const anomaly = {
|
|
767
|
+
type: 'paths_ignored_filtered',
|
|
768
|
+
message: 'Auto-commit succeeded but filtered gitignored paths: ' + droppedPaths.join(', '),
|
|
769
|
+
ignoredPaths: droppedPaths,
|
|
770
|
+
};
|
|
771
|
+
// task 5.1: emit loud direct stderr block, bypassing reporter dedup/buffering
|
|
772
|
+
process.stderr.write(_formatAutoCommitIgnoreBlock(iteration, anomaly));
|
|
773
|
+
return {
|
|
774
|
+
attempted: true,
|
|
775
|
+
committed: true,
|
|
776
|
+
anomaly,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
472
779
|
return { attempted: true, committed: true, anomaly: null };
|
|
473
780
|
} catch (err) {
|
|
474
781
|
const anomaly = {
|
|
@@ -481,6 +788,53 @@ function _autoCommit(iteration, opts = {}) {
|
|
|
481
788
|
}
|
|
482
789
|
}
|
|
483
790
|
|
|
791
|
+
/**
|
|
792
|
+
* Filter gitignored paths out of a list using `git check-ignore --stdin`.
|
|
793
|
+
*
|
|
794
|
+
* Exit-code semantics of `git check-ignore`:
|
|
795
|
+
* 0 – at least one path is ignored; stdout lists the ignored paths.
|
|
796
|
+
* 1 – no paths are ignored (Node's execFileSync throws; we catch status===1).
|
|
797
|
+
* other / ENOENT / any thrown error – fallback: treat all paths as kept.
|
|
798
|
+
*
|
|
799
|
+
* @param {string[]} paths - Repo-relative paths to test.
|
|
800
|
+
* @param {string} cwd - Working directory for the git command.
|
|
801
|
+
* @returns {{ kept: string[], dropped: string[] }}
|
|
802
|
+
*/
|
|
803
|
+
function _filterGitignored(paths, cwd) {
|
|
804
|
+
if (!Array.isArray(paths) || paths.length === 0) {
|
|
805
|
+
return { kept: [], dropped: [] };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
try {
|
|
809
|
+
const stdout = childProcess.execFileSync(
|
|
810
|
+
'git',
|
|
811
|
+
['check-ignore', '--stdin'],
|
|
812
|
+
{
|
|
813
|
+
input: paths.join('\n'),
|
|
814
|
+
cwd: cwd || process.cwd(),
|
|
815
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
816
|
+
encoding: 'utf8',
|
|
817
|
+
}
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
// Exit code 0: stdout lists ignored paths (one per line).
|
|
821
|
+
const dropped = stdout
|
|
822
|
+
.split('\n')
|
|
823
|
+
.map((l) => l.trim())
|
|
824
|
+
.filter(Boolean);
|
|
825
|
+
const droppedSet = new Set(dropped);
|
|
826
|
+
const kept = paths.filter((p) => !droppedSet.has(p));
|
|
827
|
+
return { kept, dropped };
|
|
828
|
+
} catch (err) {
|
|
829
|
+
// exit status 1 means "no paths ignored" — treat as success with no drops.
|
|
830
|
+
if (err && err.status === 1) {
|
|
831
|
+
return { kept: paths.slice(), dropped: [] };
|
|
832
|
+
}
|
|
833
|
+
// Any other error (ENOENT, unexpected exit code, etc.) — fallback, never crash.
|
|
834
|
+
return { kept: paths.slice(), dropped: [] };
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
484
838
|
/**
|
|
485
839
|
* Build the explicit per-iteration git staging allowlist.
|
|
486
840
|
*
|
|
@@ -531,6 +885,11 @@ function _completedTaskDelta(beforeTasks, afterTasks) {
|
|
|
531
885
|
/**
|
|
532
886
|
* Build a task-aware commit message for an iteration.
|
|
533
887
|
*
|
|
888
|
+
* The subject line (first line) is kept short — conventional git tooling
|
|
889
|
+
* assumes ~50–72 characters — so `git log --oneline` stays readable even when
|
|
890
|
+
* the underlying task description is a multi-sentence normative blob. The
|
|
891
|
+
* full, untruncated task descriptions are preserved in the commit body.
|
|
892
|
+
*
|
|
534
893
|
* @param {number} iteration
|
|
535
894
|
* @param {Array<object>} completedTasks
|
|
536
895
|
* @returns {string}
|
|
@@ -540,14 +899,55 @@ function _formatAutoCommitMessage(iteration, completedTasks) {
|
|
|
540
899
|
return '';
|
|
541
900
|
}
|
|
542
901
|
|
|
543
|
-
const
|
|
902
|
+
const rawSummary = completedTasks.length === 1
|
|
544
903
|
? completedTasks[0].description
|
|
545
904
|
: `complete ${completedTasks.length} tasks`;
|
|
905
|
+
|
|
906
|
+
const prefix = `Ralph iteration ${iteration}: `;
|
|
907
|
+
const subjectBudget = Math.max(20, SUBJECT_MAX_LENGTH - prefix.length);
|
|
908
|
+
const summary = _truncateSubjectSummary(rawSummary, subjectBudget);
|
|
909
|
+
|
|
546
910
|
const taskLines = completedTasks.map(
|
|
547
911
|
(task) => `- [x] ${task.fullDescription || task.description}`
|
|
548
912
|
);
|
|
549
913
|
|
|
550
|
-
return
|
|
914
|
+
return `${prefix}${summary}\n\nTasks completed:\n${taskLines.join('\n')}`;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const SUBJECT_MAX_LENGTH = 72;
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Reduce a task description to a short, single-line commit subject.
|
|
921
|
+
*
|
|
922
|
+
* Strategy:
|
|
923
|
+
* 1. Collapse whitespace onto a single line.
|
|
924
|
+
* 2. Prefer the first sentence (up to `.`, `!`, `?`) when it is not itself
|
|
925
|
+
* longer than the allowed budget.
|
|
926
|
+
* 3. Otherwise hard-truncate at a word boundary and append an ellipsis.
|
|
927
|
+
*
|
|
928
|
+
* @param {string} text
|
|
929
|
+
* @param {number} budget
|
|
930
|
+
* @returns {string}
|
|
931
|
+
*/
|
|
932
|
+
function _truncateSubjectSummary(text, budget) {
|
|
933
|
+
const oneLine = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
|
|
934
|
+
if (oneLine.length === 0) return '';
|
|
935
|
+
if (oneLine.length <= budget) return oneLine;
|
|
936
|
+
|
|
937
|
+
const sentenceMatch = oneLine.match(/^(.+?[.!?])(\s|$)/);
|
|
938
|
+
if (sentenceMatch) {
|
|
939
|
+
const candidate = sentenceMatch[1].trim();
|
|
940
|
+
if (candidate.length > 0 && candidate.length <= budget) {
|
|
941
|
+
return candidate;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const ellipsis = '…';
|
|
946
|
+
const hardBudget = Math.max(1, budget - ellipsis.length);
|
|
947
|
+
const sliced = oneLine.slice(0, hardBudget);
|
|
948
|
+
const lastSpace = sliced.lastIndexOf(' ');
|
|
949
|
+
const cut = lastSpace > Math.floor(hardBudget / 2) ? sliced.slice(0, lastSpace) : sliced;
|
|
950
|
+
return `${cut.replace(/[\s,;:.!?-]+$/, '')}${ellipsis}`;
|
|
551
951
|
}
|
|
552
952
|
|
|
553
953
|
/**
|
|
@@ -557,12 +957,62 @@ function _formatAutoCommitMessage(iteration, completedTasks) {
|
|
|
557
957
|
* @param {Array<object>} recentHistory
|
|
558
958
|
* @returns {string}
|
|
559
959
|
*/
|
|
960
|
+
function _firstNonEmptyLine(text, limit) {
|
|
961
|
+
if (!text) return '';
|
|
962
|
+
const lines = text.split('\n');
|
|
963
|
+
for (const line of lines) {
|
|
964
|
+
const trimmed = line.trim();
|
|
965
|
+
if (trimmed.length > 0) {
|
|
966
|
+
return trimmed.slice(0, limit);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return '';
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function _failureFingerprint(entry, errorEntries) {
|
|
973
|
+
let stderrHead = '';
|
|
974
|
+
if (errorEntries) {
|
|
975
|
+
const match = errors.matchIteration(errorEntries, entry.iteration);
|
|
976
|
+
stderrHead = _firstNonEmptyLine(match && match.stderr, 120);
|
|
977
|
+
}
|
|
978
|
+
// A "no promise emitted" iteration is also a distinguishable failure mode
|
|
979
|
+
// even when exitCode===0 and there's no stderr (e.g. the agent explicitly
|
|
980
|
+
// refuses to continue). Encoding it in the fingerprint lets the dedup
|
|
981
|
+
// collapse repeated hand-off iterations into a single actionable line
|
|
982
|
+
// instead of N identical bullets.
|
|
983
|
+
const noPromise = !entry.completionDetected && !entry.taskDetected;
|
|
984
|
+
return JSON.stringify({
|
|
985
|
+
failureStage: entry.failureStage || '',
|
|
986
|
+
exitCode: entry.exitCode,
|
|
987
|
+
stderrHead,
|
|
988
|
+
noPromise,
|
|
989
|
+
commitAnomalyType: entry.commitAnomalyType || '',
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function _isEmptyFingerprint(fingerprint) {
|
|
994
|
+
try {
|
|
995
|
+
const obj = JSON.parse(fingerprint);
|
|
996
|
+
return (
|
|
997
|
+
!obj.failureStage &&
|
|
998
|
+
obj.exitCode === 0 &&
|
|
999
|
+
!obj.stderrHead &&
|
|
1000
|
+
!obj.noPromise &&
|
|
1001
|
+
!obj.commitAnomalyType
|
|
1002
|
+
);
|
|
1003
|
+
} catch {
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
560
1008
|
function _buildIterationFeedback(recentHistory, errorEntries) {
|
|
561
1009
|
if (!Array.isArray(recentHistory) || recentHistory.length === 0) {
|
|
562
1010
|
return '';
|
|
563
1011
|
}
|
|
564
1012
|
|
|
565
1013
|
const problemLines = [];
|
|
1014
|
+
// Track fingerprint -> first iteration number for dedup
|
|
1015
|
+
const fingerprintSeen = new Map();
|
|
566
1016
|
|
|
567
1017
|
for (const entry of recentHistory) {
|
|
568
1018
|
const issues = [];
|
|
@@ -579,37 +1029,90 @@ function _buildIterationFeedback(recentHistory, errorEntries) {
|
|
|
579
1029
|
issues.push(`commit anomaly: ${entry.commitAnomaly}`);
|
|
580
1030
|
}
|
|
581
1031
|
|
|
582
|
-
if (!entry.filesChanged || entry.filesChanged.length === 0) {
|
|
583
|
-
issues.push('no files changed');
|
|
584
|
-
}
|
|
585
|
-
|
|
586
1032
|
if (!entry.completionDetected && !entry.taskDetected) {
|
|
587
1033
|
issues.push('no loop promise emitted');
|
|
588
1034
|
}
|
|
589
1035
|
|
|
590
1036
|
if (issues.length > 0) {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1037
|
+
// Compute fingerprint for dedup
|
|
1038
|
+
const fp = _failureFingerprint(entry, errorEntries);
|
|
1039
|
+
const isRealFailure = !_isEmptyFingerprint(fp);
|
|
1040
|
+
|
|
1041
|
+
// paths_ignored_filtered and all_paths_ignored are exempt from dedup:
|
|
1042
|
+
// every occurrence must produce its own distinct line so the agent
|
|
1043
|
+
// sees the full per-iteration history of gitignore filtering events.
|
|
1044
|
+
const isIgnoreFilterAnomaly =
|
|
1045
|
+
entry.commitAnomalyType === 'paths_ignored_filtered' ||
|
|
1046
|
+
entry.commitAnomalyType === 'all_paths_ignored';
|
|
1047
|
+
|
|
1048
|
+
if (isRealFailure && fingerprintSeen.has(fp) && !isIgnoreFilterAnomaly) {
|
|
1049
|
+
const firstIteration = fingerprintSeen.get(fp);
|
|
1050
|
+
problemLines.push(
|
|
1051
|
+
`- Iteration ${entry.iteration}: same failure as iteration ${firstIteration} (see above).`
|
|
1052
|
+
);
|
|
1053
|
+
} else {
|
|
1054
|
+
if (isRealFailure && !isIgnoreFilterAnomaly) fingerprintSeen.set(fp, entry.iteration);
|
|
1055
|
+
|
|
1056
|
+
let line = `- Iteration ${entry.iteration}: ${issues.join('; ')}.`;
|
|
1057
|
+
|
|
1058
|
+
// For paths_ignored_filtered / all_paths_ignored, append the first two
|
|
1059
|
+
// ignored paths inline (with a (+N more) suffix) so the agent can see
|
|
1060
|
+
// the exact files without diving into history. This replaces the
|
|
1061
|
+
// generic commit-anomaly text with a richer per-iteration signal.
|
|
1062
|
+
if (isIgnoreFilterAnomaly && Array.isArray(entry.ignoredPaths) && entry.ignoredPaths.length > 0) {
|
|
1063
|
+
const paths = entry.ignoredPaths;
|
|
1064
|
+
const shown = paths.slice(0, 2);
|
|
1065
|
+
const remaining = paths.length - shown.length;
|
|
1066
|
+
const pathStr = shown.join(', ') + (remaining > 0 ? ` (+${remaining} more)` : '');
|
|
1067
|
+
line += ` Ignored paths: ${pathStr}.`;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// When the only issue is "no loop promise emitted" (no signal, no
|
|
1071
|
+
// failureStage, exitCode 0, no commit anomaly), append a compact
|
|
1072
|
+
// suffix with tool-usage and duration to give the agent more context.
|
|
1073
|
+
const isNoPromiseOnly =
|
|
1074
|
+
issues.length === 1 &&
|
|
1075
|
+
issues[0] === 'no loop promise emitted' &&
|
|
1076
|
+
!entry.signal &&
|
|
1077
|
+
!entry.failureStage &&
|
|
1078
|
+
entry.exitCode === 0 &&
|
|
1079
|
+
!entry.commitAnomaly;
|
|
1080
|
+
|
|
1081
|
+
if (isNoPromiseOnly) {
|
|
1082
|
+
const toolParts = Array.isArray(entry.toolUsage) && entry.toolUsage.length > 0
|
|
1083
|
+
? entry.toolUsage.map(t => `${t.tool}\u00d7${t.count}`).join(', ')
|
|
1084
|
+
: null;
|
|
1085
|
+
const durationMs = entry.duration != null ? entry.duration : 0;
|
|
1086
|
+
const durationStr = durationMs > 0 ? progress._formatDuration(durationMs) : null;
|
|
1087
|
+
if (toolParts || durationStr) {
|
|
1088
|
+
const suffixParts = [];
|
|
1089
|
+
if (toolParts) suffixParts.push(`Tools used: ${toolParts}.`);
|
|
1090
|
+
if (durationStr) suffixParts.push(`Duration: ${durationStr}.`);
|
|
1091
|
+
line += ` ${suffixParts.join(' ')}`;
|
|
605
1092
|
}
|
|
606
|
-
|
|
607
|
-
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (_isFailedIteration(entry) && errorEntries) {
|
|
1096
|
+
const errorDetails = _extractErrorForIteration(errorEntries, entry.iteration);
|
|
1097
|
+
if (errorDetails) {
|
|
1098
|
+
line += '\n Error output:';
|
|
1099
|
+
if (errorDetails.signal) {
|
|
1100
|
+
line += `\n signal: ${errorDetails.signal}`;
|
|
1101
|
+
}
|
|
1102
|
+
if (errorDetails.failureStage) {
|
|
1103
|
+
line += `\n failure stage: ${errorDetails.failureStage}`;
|
|
1104
|
+
}
|
|
1105
|
+
if (errorDetails.stderr) {
|
|
1106
|
+
line += `\n ${errorDetails.stderr}`;
|
|
1107
|
+
}
|
|
1108
|
+
if (errorDetails.stdout) {
|
|
1109
|
+
line += `\n stdout: ${errorDetails.stdout}`;
|
|
1110
|
+
}
|
|
608
1111
|
}
|
|
609
1112
|
}
|
|
610
|
-
}
|
|
611
1113
|
|
|
612
|
-
|
|
1114
|
+
problemLines.push(line);
|
|
1115
|
+
}
|
|
613
1116
|
}
|
|
614
1117
|
}
|
|
615
1118
|
|
|
@@ -632,8 +1135,8 @@ function _extractErrorForIteration(errorEntries, iteration) {
|
|
|
632
1135
|
let stderr = match.stderr || '';
|
|
633
1136
|
let stdout = match.stdout || '';
|
|
634
1137
|
|
|
635
|
-
if (stderr.length >
|
|
636
|
-
if (stdout.length >
|
|
1138
|
+
if (stderr.length > 500) stderr = stderr.substring(0, 500) + '...';
|
|
1139
|
+
if (stdout.length > 200) stdout = stdout.substring(0, 200) + '...';
|
|
637
1140
|
|
|
638
1141
|
return {
|
|
639
1142
|
stderr,
|
|
@@ -650,6 +1153,55 @@ function _getCurrentTaskDescription(tasksBefore) {
|
|
|
650
1153
|
return 'N/A';
|
|
651
1154
|
}
|
|
652
1155
|
|
|
1156
|
+
/**
|
|
1157
|
+
* Return the structured { number, description } of the current task for the
|
|
1158
|
+
* progress reporter. Unlike `_getCurrentTaskDescription` this preserves the
|
|
1159
|
+
* task number separately so the status line can render "task 4.7" even when
|
|
1160
|
+
* the description is later truncated.
|
|
1161
|
+
*
|
|
1162
|
+
* @param {Array<object>} tasksBefore
|
|
1163
|
+
* @returns {{ number: string, description: string }}
|
|
1164
|
+
*/
|
|
1165
|
+
/**
|
|
1166
|
+
* Produce a short one-line reason for a failed iteration, suitable for the
|
|
1167
|
+
* progress reporter. Prefers the stderr first line; falls back to the exit
|
|
1168
|
+
* code / signal / failure stage so the operator always sees *something*.
|
|
1169
|
+
*
|
|
1170
|
+
* @param {object} result
|
|
1171
|
+
* @returns {string}
|
|
1172
|
+
*/
|
|
1173
|
+
function _summarizeFailure(result) {
|
|
1174
|
+
if (!result || typeof result !== 'object') return 'unknown failure';
|
|
1175
|
+
|
|
1176
|
+
const stderrHead = _firstNonEmptyLine(result.stderr, 120);
|
|
1177
|
+
if (stderrHead) return stderrHead;
|
|
1178
|
+
|
|
1179
|
+
const parts = [];
|
|
1180
|
+
if (result.failureStage) parts.push(result.failureStage);
|
|
1181
|
+
if (result.signal) parts.push(`signal=${result.signal}`);
|
|
1182
|
+
if (
|
|
1183
|
+
typeof result.exitCode === 'number' &&
|
|
1184
|
+
result.exitCode !== 0
|
|
1185
|
+
) {
|
|
1186
|
+
parts.push(`exit=${result.exitCode}`);
|
|
1187
|
+
}
|
|
1188
|
+
return parts.length > 0 ? parts.join(' ') : 'iteration failed';
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function _getCurrentTaskMeta(tasksBefore) {
|
|
1192
|
+
if (!Array.isArray(tasksBefore) || tasksBefore.length === 0) {
|
|
1193
|
+
return { number: '', description: '' };
|
|
1194
|
+
}
|
|
1195
|
+
const incomplete = tasksBefore.find(
|
|
1196
|
+
(task) => task && task.status !== 'completed'
|
|
1197
|
+
);
|
|
1198
|
+
if (!incomplete) return { number: '', description: '' };
|
|
1199
|
+
return {
|
|
1200
|
+
number: incomplete.number ? String(incomplete.number) : '',
|
|
1201
|
+
description: incomplete.description || incomplete.fullDescription || '',
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
|
|
653
1205
|
function _cleanupCompletedErrors(ralphDir, verbose) {
|
|
654
1206
|
let archivePath = null;
|
|
655
1207
|
|
|
@@ -785,12 +1337,16 @@ module.exports = {
|
|
|
785
1337
|
_validateOptions,
|
|
786
1338
|
_autoCommit,
|
|
787
1339
|
_buildAutoCommitAllowlist,
|
|
1340
|
+
_filterGitignored,
|
|
788
1341
|
_resolveStartIteration,
|
|
789
1342
|
_completedTaskDelta,
|
|
790
1343
|
_formatAutoCommitMessage,
|
|
1344
|
+
_truncateSubjectSummary,
|
|
791
1345
|
_buildIterationFeedback,
|
|
792
1346
|
_extractErrorForIteration,
|
|
793
1347
|
_getCurrentTaskDescription,
|
|
1348
|
+
_getCurrentTaskMeta,
|
|
1349
|
+
_summarizeFailure,
|
|
794
1350
|
_cleanupCompletedErrors,
|
|
795
1351
|
_detectProtectedCommitArtifacts,
|
|
796
1352
|
_gitErrorMessage,
|
|
@@ -799,4 +1355,7 @@ module.exports = {
|
|
|
799
1355
|
_failureStageForError,
|
|
800
1356
|
_errorText,
|
|
801
1357
|
_appendFatalIterationFailure,
|
|
1358
|
+
_failureFingerprint,
|
|
1359
|
+
_firstNonEmptyLine,
|
|
1360
|
+
_iterationIsStalled,
|
|
802
1361
|
};
|