@neriros/ralphy 2.7.7 → 2.7.9
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/README.md +28 -13
- package/dist/cli/index.js +174 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -113,6 +113,14 @@ Defaults are written to `ralphy.config.json` on first run; CLI flags override co
|
|
|
113
113
|
"appendPrompt": "Always run lint before committing.",
|
|
114
114
|
"createPrOnSuccess": true,
|
|
115
115
|
"prBaseBranch": "main",
|
|
116
|
+
"fixCiOnFailure": true,
|
|
117
|
+
"maxCiFixAttempts": 5,
|
|
118
|
+
"ciPollIntervalSeconds": 30,
|
|
119
|
+
"maxRuntimeMinutesPerTask": 0,
|
|
120
|
+
"maxConsecutiveFailuresPerTask": 5,
|
|
121
|
+
"iterationDelaySeconds": 0,
|
|
122
|
+
"logRawStream": false,
|
|
123
|
+
"taskVerbose": false,
|
|
116
124
|
}
|
|
117
125
|
```
|
|
118
126
|
|
|
@@ -130,6 +138,12 @@ Use `setupScript` (run inside the worktree right after scaffolding) to install d
|
|
|
130
138
|
|
|
131
139
|
**`createPrOnSuccess`** (or `--create-pr`) pushes the worker's branch and opens a GitHub PR via `gh` after a clean exit. Requires `--worktree` (the PR needs a branch to point at) and the `gh` CLI authenticated. The PR title is `<ID>: <title>`, the body links the Linear issue. If a PR already exists for the branch the existing URL is reported (idempotent for retries). `prBaseBranch` defaults to `main`.
|
|
132
140
|
|
|
141
|
+
**`fixCiOnFailure`** (or `--fix-ci`) watches the PR's checks via `gh pr checks` and, on failure, fetches the failed-run logs (`gh run view --log-failed`), appends them to `proposal.md` under `## Steering`, re-spawns the task loop in the worktree, and pushes the new commits — repeating until checks go green or `maxCiFixAttempts` is hit (default 5, polling interval `ciPollIntervalSeconds` defaults to 30s). Requires `--create-pr`.
|
|
142
|
+
|
|
143
|
+
When `fixCiOnFailure` is enabled, the issue is **not** moved to `doneStatus` (and `doneLabel` is not applied, and the issue is not marked processed in `.ralph/agent-state.json`) until CI actually goes green. If the fix loop exhausts its attempts the worker is treated as failed for completion-marking purposes and the issue will be re-picked-up on the next poll (the resume-in-progress filter ensures that).
|
|
144
|
+
|
|
145
|
+
Every CLI flag is also configurable in `ralphy.config.json`; CLI values override config when both are set. The agent forwards `maxRuntimeMinutesPerTask` / `maxConsecutiveFailuresPerTask` / `iterationDelaySeconds` / `logRawStream` / `taskVerbose` to each spawned `ralph task` worker.
|
|
146
|
+
|
|
133
147
|
Failed workers (non-zero exit) are not marked processed, so they'll be retried on the next poll. SIGINT/SIGTERM cleanly stops polling and kills active workers. All Linear side effects are best-effort — failures log a warning but never block the task loop.
|
|
134
148
|
|
|
135
149
|
## CLI Options
|
|
@@ -153,19 +167,20 @@ Failed workers (non-zero exit) are not marked processed, so they'll be retried o
|
|
|
153
167
|
|
|
154
168
|
### Agent mode flags
|
|
155
169
|
|
|
156
|
-
| Option | Description
|
|
157
|
-
| ----------------------------- |
|
|
158
|
-
| `--linear-team <key>` | Linear team key (e.g. `ENG`)
|
|
159
|
-
| `--linear-assignee <id>` | Filter by assignee (user id, email, or `me`)
|
|
160
|
-
| `--linear-status <name>` | Filter by status name (repeatable)
|
|
161
|
-
| `--linear-label <name>` | Filter by label name (repeatable, any-of)
|
|
162
|
-
| `--poll-interval <s>` | Seconds between Linear polls (default: 60)
|
|
163
|
-
| `--concurrency <n>` | Max concurrent task loops (default: 1)
|
|
164
|
-
| `--worktree` | Run each task in its own git worktree
|
|
165
|
-
| `--in-progress-status <name>` | Linear status to set when work starts
|
|
166
|
-
| `--done-status <name>` | Linear status to set on successful completion
|
|
167
|
-
| `--done-label <name>` | Linear label to add on successful completion
|
|
168
|
-
| `--create-pr` | Push worker branch + open a GitHub PR on success (needs `--worktree`)
|
|
170
|
+
| Option | Description |
|
|
171
|
+
| ----------------------------- | ---------------------------------------------------------------------------- |
|
|
172
|
+
| `--linear-team <key>` | Linear team key (e.g. `ENG`) |
|
|
173
|
+
| `--linear-assignee <id>` | Filter by assignee (user id, email, or `me`) |
|
|
174
|
+
| `--linear-status <name>` | Filter by status name (repeatable) |
|
|
175
|
+
| `--linear-label <name>` | Filter by label name (repeatable, any-of) |
|
|
176
|
+
| `--poll-interval <s>` | Seconds between Linear polls (default: 60) |
|
|
177
|
+
| `--concurrency <n>` | Max concurrent task loops (default: 1) |
|
|
178
|
+
| `--worktree` | Run each task in its own git worktree |
|
|
179
|
+
| `--in-progress-status <name>` | Linear status to set when work starts |
|
|
180
|
+
| `--done-status <name>` | Linear status to set on successful completion |
|
|
181
|
+
| `--done-label <name>` | Linear label to add on successful completion |
|
|
182
|
+
| `--create-pr` | Push worker branch + open a GitHub PR on success (needs `--worktree`) |
|
|
183
|
+
| `--fix-ci` | After PR opens, re-run task on CI failures until green (needs `--create-pr`) |
|
|
169
184
|
|
|
170
185
|
## OpenSpec Flow
|
|
171
186
|
|
package/dist/cli/index.js
CHANGED
|
@@ -56157,6 +56157,7 @@ var HELP_TEXT = [
|
|
|
56157
56157
|
" --done-status <name> Linear status to set when work completes successfully",
|
|
56158
56158
|
" --done-label <name> Linear label to add when work completes successfully",
|
|
56159
56159
|
" --create-pr Push the worker branch and open a GitHub PR on success (needs --worktree)",
|
|
56160
|
+
" --fix-ci After opening the PR, re-run the task on CI failures until green (needs --create-pr)",
|
|
56160
56161
|
"",
|
|
56161
56162
|
" --help, -h Show this help message",
|
|
56162
56163
|
"",
|
|
@@ -56197,7 +56198,8 @@ async function parseArgs(argv) {
|
|
|
56197
56198
|
inProgressStatus: "",
|
|
56198
56199
|
doneStatus: "",
|
|
56199
56200
|
doneLabel: "",
|
|
56200
|
-
createPr: false
|
|
56201
|
+
createPr: false,
|
|
56202
|
+
fixCi: false
|
|
56201
56203
|
};
|
|
56202
56204
|
let expectModel = false;
|
|
56203
56205
|
let expectModelFlag = false;
|
|
@@ -56421,6 +56423,9 @@ async function parseArgs(argv) {
|
|
|
56421
56423
|
case "--create-pr":
|
|
56422
56424
|
result2.createPr = true;
|
|
56423
56425
|
break;
|
|
56426
|
+
case "--fix-ci":
|
|
56427
|
+
result2.fixCi = true;
|
|
56428
|
+
break;
|
|
56424
56429
|
default:
|
|
56425
56430
|
if (VALID_MODES.has(arg)) {
|
|
56426
56431
|
result2.mode = arg;
|
|
@@ -69845,6 +69850,11 @@ var RalphyConfigSchema = exports_external.object({
|
|
|
69845
69850
|
pollIntervalSeconds: exports_external.number().int().positive().default(60),
|
|
69846
69851
|
maxIterationsPerTask: exports_external.number().int().nonnegative().default(0),
|
|
69847
69852
|
maxCostUsdPerTask: exports_external.number().nonnegative().default(0),
|
|
69853
|
+
maxRuntimeMinutesPerTask: exports_external.number().nonnegative().default(0),
|
|
69854
|
+
maxConsecutiveFailuresPerTask: exports_external.number().int().nonnegative().default(5),
|
|
69855
|
+
iterationDelaySeconds: exports_external.number().int().nonnegative().default(0),
|
|
69856
|
+
logRawStream: exports_external.boolean().default(false),
|
|
69857
|
+
taskVerbose: exports_external.boolean().default(false),
|
|
69848
69858
|
useWorktree: exports_external.boolean().default(false),
|
|
69849
69859
|
cleanupWorktreeOnSuccess: exports_external.boolean().default(false),
|
|
69850
69860
|
setupScript: exports_external.string().optional(),
|
|
@@ -69852,6 +69862,9 @@ var RalphyConfigSchema = exports_external.object({
|
|
|
69852
69862
|
appendPrompt: exports_external.string().optional(),
|
|
69853
69863
|
createPrOnSuccess: exports_external.boolean().default(false),
|
|
69854
69864
|
prBaseBranch: exports_external.string().default("main"),
|
|
69865
|
+
fixCiOnFailure: exports_external.boolean().default(false),
|
|
69866
|
+
maxCiFixAttempts: exports_external.number().int().positive().default(5),
|
|
69867
|
+
ciPollIntervalSeconds: exports_external.number().int().positive().default(30),
|
|
69855
69868
|
engine: exports_external.enum(["claude", "codex"]).default("claude"),
|
|
69856
69869
|
model: exports_external.enum(["haiku", "sonnet", "opus"]).default("opus"),
|
|
69857
69870
|
linear: exports_external.object({
|
|
@@ -70187,6 +70200,80 @@ async function createPullRequest(input, runner) {
|
|
|
70187
70200
|
return { url, created: true };
|
|
70188
70201
|
}
|
|
70189
70202
|
|
|
70203
|
+
// apps/cli/src/agent/ci.ts
|
|
70204
|
+
var PR_CHECKS_FIELDS = "name,bucket,link,workflow,event";
|
|
70205
|
+
async function getPrChecksStatus(prRef, runner, cwd2) {
|
|
70206
|
+
const out = await runner.run(["gh", "pr", "checks", prRef, "--json", PR_CHECKS_FIELDS], cwd2);
|
|
70207
|
+
const checks = JSON.parse(out.stdout || "[]").filter((c) => c.bucket !== "skipping");
|
|
70208
|
+
if (checks.some((c) => c.bucket === "pending")) {
|
|
70209
|
+
return { bucket: "pending", failedRunIds: [] };
|
|
70210
|
+
}
|
|
70211
|
+
const failed = checks.filter((c) => c.bucket === "fail" || c.bucket === "cancel");
|
|
70212
|
+
if (failed.length === 0)
|
|
70213
|
+
return { bucket: "pass", failedRunIds: [] };
|
|
70214
|
+
const ids = new Set;
|
|
70215
|
+
for (const c of failed) {
|
|
70216
|
+
const m = c.link?.match(/\/actions\/runs\/(\d+)/);
|
|
70217
|
+
if (m)
|
|
70218
|
+
ids.add(m[1]);
|
|
70219
|
+
}
|
|
70220
|
+
return { bucket: "fail", failedRunIds: [...ids] };
|
|
70221
|
+
}
|
|
70222
|
+
async function fetchFailedRunLogs(runIds, runner, cwd2, maxCharsPerRun = 4000) {
|
|
70223
|
+
const chunks = [];
|
|
70224
|
+
for (const id of runIds) {
|
|
70225
|
+
try {
|
|
70226
|
+
const r = await runner.run(["gh", "run", "view", id, "--log-failed"], cwd2);
|
|
70227
|
+
const text = r.stdout.trim();
|
|
70228
|
+
const truncated = text.length > maxCharsPerRun ? text.slice(0, maxCharsPerRun) + `
|
|
70229
|
+
\u2026[truncated ${text.length - maxCharsPerRun} chars]` : text;
|
|
70230
|
+
chunks.push(`--- run ${id} ---
|
|
70231
|
+
${truncated}`);
|
|
70232
|
+
} catch (err) {
|
|
70233
|
+
chunks.push(`--- run ${id} ---
|
|
70234
|
+
(failed to fetch logs: ${err.message})`);
|
|
70235
|
+
}
|
|
70236
|
+
}
|
|
70237
|
+
return chunks.join(`
|
|
70238
|
+
|
|
70239
|
+
`);
|
|
70240
|
+
}
|
|
70241
|
+
async function fixCiUntilGreen(deps, opts) {
|
|
70242
|
+
for (let attempt2 = 1;attempt2 <= opts.maxAttempts; attempt2++) {
|
|
70243
|
+
while (true) {
|
|
70244
|
+
if (deps.cancelled?.())
|
|
70245
|
+
return { success: false, attempts: attempt2 - 1, reason: "cancelled" };
|
|
70246
|
+
const s = await deps.getStatus();
|
|
70247
|
+
if (s.bucket === "pass") {
|
|
70248
|
+
deps.log(`\u2713 CI green for PR (after ${attempt2 - 1} fix attempts)`, "green");
|
|
70249
|
+
return { success: true, attempts: attempt2 - 1 };
|
|
70250
|
+
}
|
|
70251
|
+
if (s.bucket === "fail") {
|
|
70252
|
+
deps.log(`\u2717 CI failing (attempt ${attempt2}/${opts.maxAttempts}) \u2014 fetching logs and re-running task`, "yellow");
|
|
70253
|
+
const logs = await deps.getFailedLogs(s.failedRunIds);
|
|
70254
|
+
const steering = `CI is failing on this PR. Investigate and fix:
|
|
70255
|
+
|
|
70256
|
+
\`\`\`
|
|
70257
|
+
${logs}
|
|
70258
|
+
\`\`\``;
|
|
70259
|
+
const code = await deps.runTaskWithSteering(steering);
|
|
70260
|
+
if (code !== 0) {
|
|
70261
|
+
deps.log(`! task loop exited code ${code} during CI fix attempt ${attempt2}`, "red");
|
|
70262
|
+
}
|
|
70263
|
+
try {
|
|
70264
|
+
await deps.pushBranch();
|
|
70265
|
+
} catch (err) {
|
|
70266
|
+
deps.log(`! push failed during CI fix: ${err.message}`, "red");
|
|
70267
|
+
return { success: false, attempts: attempt2, reason: "push-failed" };
|
|
70268
|
+
}
|
|
70269
|
+
break;
|
|
70270
|
+
}
|
|
70271
|
+
await deps.sleep(opts.pollIntervalSeconds * 1000);
|
|
70272
|
+
}
|
|
70273
|
+
}
|
|
70274
|
+
return { success: false, attempts: opts.maxAttempts, reason: "max-attempts" };
|
|
70275
|
+
}
|
|
70276
|
+
|
|
70190
70277
|
// apps/cli/src/components/AgentMode.tsx
|
|
70191
70278
|
var jsx_dev_runtime9 = __toESM(require_jsx_dev_runtime(), 1);
|
|
70192
70279
|
import { join as join14 } from "path";
|
|
@@ -70321,36 +70408,54 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
|
|
|
70321
70408
|
return changeName;
|
|
70322
70409
|
},
|
|
70323
70410
|
spawnWorker: (changeName) => {
|
|
70324
|
-
const
|
|
70325
|
-
|
|
70326
|
-
|
|
70327
|
-
|
|
70328
|
-
|
|
70329
|
-
|
|
70330
|
-
|
|
70331
|
-
|
|
70332
|
-
|
|
70333
|
-
|
|
70334
|
-
|
|
70335
|
-
|
|
70336
|
-
|
|
70337
|
-
|
|
70338
|
-
|
|
70411
|
+
const buildTaskCmd = () => {
|
|
70412
|
+
const c = [
|
|
70413
|
+
process.execPath,
|
|
70414
|
+
process.argv[1] ?? "",
|
|
70415
|
+
"task",
|
|
70416
|
+
"--name",
|
|
70417
|
+
changeName,
|
|
70418
|
+
"--" + (args.engineSet ? args.engine : cfg.engine),
|
|
70419
|
+
args.engineSet ? args.model : cfg.model
|
|
70420
|
+
];
|
|
70421
|
+
const maxIter = args.maxIterations || cfg.maxIterationsPerTask;
|
|
70422
|
+
if (maxIter > 0)
|
|
70423
|
+
c.push("--max-iterations", String(maxIter));
|
|
70424
|
+
const maxCost = args.maxCostUsd || cfg.maxCostUsdPerTask;
|
|
70425
|
+
if (maxCost > 0)
|
|
70426
|
+
c.push("--max-cost", String(maxCost));
|
|
70427
|
+
const maxRuntime = args.maxRuntimeMinutes || cfg.maxRuntimeMinutesPerTask;
|
|
70428
|
+
if (maxRuntime > 0)
|
|
70429
|
+
c.push("--max-runtime", String(maxRuntime));
|
|
70430
|
+
const maxFailures = args.maxConsecutiveFailures !== 5 ? args.maxConsecutiveFailures : cfg.maxConsecutiveFailuresPerTask;
|
|
70431
|
+
if (maxFailures !== 5)
|
|
70432
|
+
c.push("--max-failures", String(maxFailures));
|
|
70433
|
+
const delay2 = args.delay || cfg.iterationDelaySeconds;
|
|
70434
|
+
if (delay2 > 0)
|
|
70435
|
+
c.push("--delay", String(delay2));
|
|
70436
|
+
if (args.log || cfg.logRawStream)
|
|
70437
|
+
c.push("--log");
|
|
70438
|
+
if (args.verbose || cfg.taskVerbose)
|
|
70439
|
+
c.push("--verbose");
|
|
70440
|
+
return c;
|
|
70441
|
+
};
|
|
70339
70442
|
const cwd2 = cwdByChange.get(changeName) ?? projectRoot;
|
|
70340
70443
|
const proc = Bun.spawn({
|
|
70341
|
-
cmd,
|
|
70444
|
+
cmd: buildTaskCmd(),
|
|
70342
70445
|
cwd: cwd2,
|
|
70343
70446
|
stdout: "ignore",
|
|
70344
70447
|
stderr: "ignore",
|
|
70345
70448
|
stdin: "ignore"
|
|
70346
70449
|
});
|
|
70347
70450
|
const wantPr = args.createPr || cfg.createPrOnSuccess;
|
|
70451
|
+
const CI_FAILED_EXIT = 70;
|
|
70348
70452
|
const wrapped = proc.exited.then(async (code) => {
|
|
70349
70453
|
if (cfg.teardownScript) {
|
|
70350
70454
|
try {
|
|
70351
70455
|
await runScript("teardown", cfg.teardownScript, cwd2);
|
|
70352
70456
|
} catch {}
|
|
70353
70457
|
}
|
|
70458
|
+
let effectiveCode = code;
|
|
70354
70459
|
const ok = code === 0;
|
|
70355
70460
|
if (ok && wantPr) {
|
|
70356
70461
|
const branch = branchByChange.get(changeName);
|
|
@@ -70364,6 +70469,56 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
|
|
|
70364
70469
|
appendLog(` no commits ahead of ${cfg.prBaseBranch} \u2014 skipping PR`, "gray");
|
|
70365
70470
|
} else {
|
|
70366
70471
|
appendLog(` ${pr.created ? "opened" : "found existing"} PR: ${pr.url}`, "green");
|
|
70472
|
+
const wantFixCi = args.fixCi || cfg.fixCiOnFailure;
|
|
70473
|
+
if (wantFixCi) {
|
|
70474
|
+
appendLog(` watching CI for ${pr.url} (max ${cfg.maxCiFixAttempts} fix attempts)`, "gray");
|
|
70475
|
+
const proposalPath = join14(statesDirByChange.get(changeName) ?? statesDir, "..", "..", "openspec", "changes", changeName, "proposal.md");
|
|
70476
|
+
const result2 = await fixCiUntilGreen({
|
|
70477
|
+
getStatus: () => getPrChecksStatus(pr.url, bunCmdRunner, cwd2),
|
|
70478
|
+
getFailedLogs: (ids) => fetchFailedRunLogs(ids, bunCmdRunner, cwd2),
|
|
70479
|
+
runTaskWithSteering: async (steering) => {
|
|
70480
|
+
try {
|
|
70481
|
+
const file = Bun.file(proposalPath);
|
|
70482
|
+
const prev = await file.exists() ? await file.text() : "";
|
|
70483
|
+
const stamped = `
|
|
70484
|
+
|
|
70485
|
+
### CI feedback (${new Date().toISOString()})
|
|
70486
|
+
|
|
70487
|
+
${steering}
|
|
70488
|
+
`;
|
|
70489
|
+
const next = prev.includes("## Steering") ? prev.replace(/## Steering\n+([\s\S]*?)$/, (_m, rest2) => `## Steering
|
|
70490
|
+
${stamped}
|
|
70491
|
+
${rest2}`) : prev + `
|
|
70492
|
+
## Steering
|
|
70493
|
+
${stamped}
|
|
70494
|
+
`;
|
|
70495
|
+
await Bun.write(proposalPath, next);
|
|
70496
|
+
} catch (err) {
|
|
70497
|
+
appendLog(`! could not append steering: ${err.message}`, "red");
|
|
70498
|
+
}
|
|
70499
|
+
const p = Bun.spawn({
|
|
70500
|
+
cmd: buildTaskCmd(),
|
|
70501
|
+
cwd: cwd2,
|
|
70502
|
+
stdout: "ignore",
|
|
70503
|
+
stderr: "ignore",
|
|
70504
|
+
stdin: "ignore"
|
|
70505
|
+
});
|
|
70506
|
+
return p.exited;
|
|
70507
|
+
},
|
|
70508
|
+
pushBranch: async () => {
|
|
70509
|
+
await bunCmdRunner.run(["git", "push", "origin", branch], cwd2);
|
|
70510
|
+
},
|
|
70511
|
+
log: appendLog,
|
|
70512
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms))
|
|
70513
|
+
}, {
|
|
70514
|
+
maxAttempts: cfg.maxCiFixAttempts,
|
|
70515
|
+
pollIntervalSeconds: cfg.ciPollIntervalSeconds
|
|
70516
|
+
});
|
|
70517
|
+
if (!result2.success) {
|
|
70518
|
+
appendLog(`! CI fix loop gave up after ${result2.attempts} attempts (${result2.reason ?? "unknown"}) \u2014 withholding done-status until CI passes`, "red");
|
|
70519
|
+
effectiveCode = CI_FAILED_EXIT;
|
|
70520
|
+
}
|
|
70521
|
+
}
|
|
70367
70522
|
}
|
|
70368
70523
|
} catch (err) {
|
|
70369
70524
|
appendLog(`! PR create failed for ${changeName}: ${err.message}`, "red");
|
|
@@ -70371,7 +70526,7 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
|
|
|
70371
70526
|
}
|
|
70372
70527
|
}
|
|
70373
70528
|
if (useWorktree && cwd2 !== projectRoot) {
|
|
70374
|
-
if (
|
|
70529
|
+
if (effectiveCode === 0 && cfg.cleanupWorktreeOnSuccess) {
|
|
70375
70530
|
try {
|
|
70376
70531
|
await removeWorktree(projectRoot, cwd2, bunGitRunner);
|
|
70377
70532
|
appendLog(` removed worktree ${cwd2}`, "gray");
|
|
@@ -70384,7 +70539,7 @@ function AgentMode({ args, projectRoot, statesDir, tasksDir }) {
|
|
|
70384
70539
|
statesDirByChange.delete(changeName);
|
|
70385
70540
|
branchByChange.delete(changeName);
|
|
70386
70541
|
issueByChange.delete(changeName);
|
|
70387
|
-
return
|
|
70542
|
+
return effectiveCode;
|
|
70388
70543
|
});
|
|
70389
70544
|
return { exited: wrapped, kill: () => proc.kill() };
|
|
70390
70545
|
},
|