forgedock 1.23.0 → 1.23.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/bin/engine/outcome-codes.mjs +10 -1
- package/bin/lean/lean.mjs +14 -14
- package/bin/lean/lean.test.mjs +14 -0
- package/commands/orchestrate.md +5 -2
- package/docs/OUTCOME-CODES.md +6 -0
- package/package.json +1 -1
|
@@ -44,6 +44,13 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
44
44
|
DEPENDENCY_UNREADABLE: "dependency.unreadable",
|
|
45
45
|
INVALID: "invalid",
|
|
46
46
|
DECOMPOSED: "decomposed",
|
|
47
|
+
// Lean core work-on (bin/lean) needs-human causes.
|
|
48
|
+
BUILDER_BLOCKED: "builder.blocked",
|
|
49
|
+
BUILDER_NO_EVIDENCE: "builder.no_evidence",
|
|
50
|
+
BUILDER_NO_COMMIT: "builder.no_commit",
|
|
51
|
+
REVIEW_FAILED: "review.failed",
|
|
52
|
+
REVIEW_NO_VERDICT: "review.no_verdict",
|
|
53
|
+
MERGE_HEAD_MISMATCH: "merge.head_mismatch",
|
|
47
54
|
// Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
|
|
48
55
|
COMPLETED: "completed",
|
|
49
56
|
AWAITING_MERGE: "awaiting_merge",
|
|
@@ -115,7 +122,9 @@ export function codeForTerminalReason(reason) {
|
|
|
115
122
|
case "phase-complete":
|
|
116
123
|
case "investigation":
|
|
117
124
|
return OUTCOME_CODES.COMPLETED;
|
|
118
|
-
case "awaiting-merge":
|
|
125
|
+
case "awaiting-merge":
|
|
126
|
+
case "ci-pending":
|
|
127
|
+
return OUTCOME_CODES.AWAITING_MERGE;
|
|
119
128
|
case "invalid": return OUTCOME_CODES.INVALID;
|
|
120
129
|
case "decomposed": return OUTCOME_CODES.DECOMPOSED;
|
|
121
130
|
case "engine-error": return OUTCOME_CODES.ENGINE_ERROR;
|
package/bin/lean/lean.mjs
CHANGED
|
@@ -76,9 +76,9 @@ export async function runCore(o) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
let done = null, wtCreated = false;
|
|
79
|
-
async function finish(reason, detail = "", extra = {}) {
|
|
79
|
+
async function finish(reason, detail = "", extra = {}, codeOverride = null) {
|
|
80
80
|
if (done) return done; // idempotent: exactly one RUN_TERMINAL and one telemetry emit
|
|
81
|
-
const code = codeForTerminalReason(reason);
|
|
81
|
+
const code = codeOverride ?? codeForTerminalReason(reason);
|
|
82
82
|
ev({ event: "RUN_TERMINAL", reason, code });
|
|
83
83
|
await emit({ event: "RUN_TERMINAL", reason, code });
|
|
84
84
|
if (["needs-human", "engine-error"].includes(reason)) {
|
|
@@ -93,12 +93,12 @@ export async function runCore(o) {
|
|
|
93
93
|
done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0, ...extra };
|
|
94
94
|
return done;
|
|
95
95
|
}
|
|
96
|
-
async function needsHuman(why) {
|
|
96
|
+
async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR) {
|
|
97
97
|
try {
|
|
98
98
|
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}\n\n_${usageLine()}_`);
|
|
99
99
|
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
100
100
|
} catch { /* best effort */ }
|
|
101
|
-
return finish("needs-human", why);
|
|
101
|
+
return finish("needs-human", why, { code }, code);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
async function builder(n, prompt, resume) {
|
|
@@ -178,7 +178,7 @@ export async function runCore(o) {
|
|
|
178
178
|
const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
|
|
179
179
|
const verdict = result ? (result.status === "fixed" ? "valid" : result.status) : null;
|
|
180
180
|
const closing = ["invalid", "already-fixed"].includes(result?.status);
|
|
181
|
-
if (closing && !evidence.length) return await needsHuman(`Builder reported "${result.status}" without evidence, so the issue is not closed automatically.\n\n${result.summary ?? "(no summary)"}
|
|
181
|
+
if (closing && !evidence.length) return await needsHuman(`Builder reported "${result.status}" without evidence, so the issue is not closed automatically.\n\n${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_NO_EVIDENCE);
|
|
182
182
|
// The validation comment (no model call): verdict, evidence, one-line summary. For invalid/already-fixed it IS the evidence comment.
|
|
183
183
|
if (verdict) validateUrl = await post("FORGE:VALIDATE", renderValidate(result, verdict));
|
|
184
184
|
if (closing) {
|
|
@@ -186,8 +186,8 @@ export async function runCore(o) {
|
|
|
186
186
|
await label("invalid");
|
|
187
187
|
return await finish("invalid", result.summary ?? result.status);
|
|
188
188
|
}
|
|
189
|
-
if (result?.status === "blocked") return await needsHuman(`Builder is blocked: ${result.summary ?? "(no summary)"}
|
|
190
|
-
if (!files0.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}
|
|
189
|
+
if (result?.status === "blocked") return await needsHuman(`Builder is blocked: ${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_BLOCKED);
|
|
190
|
+
if (!files0.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`, OUTCOME_CODES.BUILDER_NO_COMMIT);
|
|
191
191
|
planUrl = await post("FORGE:PLAN", renderPlan(result, files0));
|
|
192
192
|
}
|
|
193
193
|
|
|
@@ -208,14 +208,14 @@ export async function runCore(o) {
|
|
|
208
208
|
return null;
|
|
209
209
|
};
|
|
210
210
|
review = await doReview();
|
|
211
|
-
if (!review) return await needsHuman("Reviewer returned no parseable verdict twice.");
|
|
211
|
+
if (!review) return await needsHuman("Reviewer returned no parseable verdict twice.", OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
212
212
|
if (review.fail) {
|
|
213
|
-
if (!sessionId) return await needsHuman(`Review FAIL and the builder session id is unknown, cannot resume: ${review.reason}
|
|
213
|
+
if (!sessionId) return await needsHuman(`Review FAIL and the builder session id is unknown, cannot resume: ${review.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
214
214
|
await builder(++attemptN, resumeBuilderPrompt(review.reason), sessionId);
|
|
215
215
|
result = (await readResult()) ?? result;
|
|
216
216
|
review = await doReview();
|
|
217
|
-
if (!review) return await needsHuman("Reviewer returned no parseable verdict twice (re-review).");
|
|
218
|
-
if (review.fail) return await needsHuman(`Review FAIL twice. Last reason: ${review.reason}
|
|
217
|
+
if (!review) return await needsHuman("Reviewer returned no parseable verdict twice (re-review).", OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
218
|
+
if (review.fail) return await needsHuman(`Review FAIL twice. Last reason: ${review.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
219
219
|
}
|
|
220
220
|
reviewUrl = await post("FORGE:REVIEW", renderReview(review));
|
|
221
221
|
ev({ event: "PHASE_COMMIT", phase: "review" });
|
|
@@ -251,13 +251,13 @@ export async function runCore(o) {
|
|
|
251
251
|
if (policy === "all") {
|
|
252
252
|
const w = await io.waitCiGreen(pr, repo, 0).catch(() => ({ rc: 2, text: "" }));
|
|
253
253
|
if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
|
|
254
|
-
if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}
|
|
254
|
+
if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
|
|
255
255
|
} else {
|
|
256
256
|
let states = [];
|
|
257
257
|
try { states = (await io.checkStates(pr)) ?? []; } catch { states = []; }
|
|
258
258
|
const st = (n) => states.find((x) => x.name === n)?.state ?? "pending";
|
|
259
259
|
const failed = required.filter((n) => st(n) === "failure");
|
|
260
|
-
if (failed.length) return await needsHuman(`Required check(s) failed on PR #${pr}: ${failed.join(", ")}
|
|
260
|
+
if (failed.length) return await needsHuman(`Required check(s) failed on PR #${pr}: ${failed.join(", ")}`, OUTCOME_CODES.CI_RED);
|
|
261
261
|
if (required.every((n) => st(n) === "success")) break;
|
|
262
262
|
}
|
|
263
263
|
if (now() - startedAt >= ciMaxMinutes * 60000) {
|
|
@@ -269,7 +269,7 @@ export async function runCore(o) {
|
|
|
269
269
|
await sleep(ciPollDelayMs(pollIndex++, random));
|
|
270
270
|
}
|
|
271
271
|
if (gateHead && !(localHead.startsWith(gateHead) || gateHead.startsWith(localHead))) {
|
|
272
|
-
return await needsHuman(`CI passed for ${gateHead.slice(0, 12)} but the reviewed head is ${localHead.slice(0, 12)}; refusing to merge an unreviewed head
|
|
272
|
+
return await needsHuman(`CI passed for ${gateHead.slice(0, 12)} but the reviewed head is ${localHead.slice(0, 12)}; refusing to merge an unreviewed head.`, OUTCOME_CODES.MERGE_HEAD_MISMATCH);
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
275
|
await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
|
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -194,6 +194,20 @@ describe("core work-on", () => {
|
|
|
194
194
|
assert.equal((await run(world({ changed: [] }))).terminalReason, "needs-human");
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
it("needs-human terminals carry a specific outcome code, never unclassified", async () => {
|
|
198
|
+
const code = async (w, extra) => {
|
|
199
|
+
const dir = tmpDir("lean-code-");
|
|
200
|
+
const r = await run(w, { dir, ...extra });
|
|
201
|
+
const term = readLog(dir, 7).filter((e) => e.event === "RUN_TERMINAL");
|
|
202
|
+
assert.equal(term.length, 1);
|
|
203
|
+
assert.equal(term[0].code, r.code);
|
|
204
|
+
return term[0].code;
|
|
205
|
+
};
|
|
206
|
+
assert.equal(await code(world({ result: { status: "blocked", summary: "need creds" } })), "builder.blocked");
|
|
207
|
+
assert.equal(await code(world({ changed: [] })), "builder.no_commit");
|
|
208
|
+
assert.equal(await code(world({ waits: [{ rc: 1, text: "lint failed" }] }), { mergeWait: "all" }), "ci.red");
|
|
209
|
+
});
|
|
210
|
+
|
|
197
211
|
it("red remote CI is needs-human; slow CI polls with backoff and ends ci-pending, never needs-human", async () => {
|
|
198
212
|
const w = world({ waits: [{ rc: 1, text: "lint failed" }] });
|
|
199
213
|
assert.equal((await run(w, { mergeWait: "all" })).terminalReason, "needs-human");
|
package/commands/orchestrate.md
CHANGED
|
@@ -43,14 +43,17 @@ Do this yourself from issue text and the code. Spawn no sub-agents here.
|
|
|
43
43
|
- **Logical:** one issue's fix is needed by another (for example, it adds the helper the other uses).
|
|
44
44
|
|
|
45
45
|
If you find a cycle, break it at the weakest (conflict) edge and say so.
|
|
46
|
-
4. **Show the plan** as one table: issue, title, size, likely files, waits-on
|
|
46
|
+
4. **Show the plan** as one table: issue, title, size, likely files, waits-on. There are no waves. Then proceed
|
|
47
47
|
without asking.
|
|
48
48
|
|
|
49
49
|
`K` = `--parallel` (default 3). `L` = `--lane` if given, otherwise the repo's base lane from forge.yaml.
|
|
50
50
|
|
|
51
51
|
## 3. Launch lanes
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
Schedule from the DAG, not in waves. An issue is **ready** when every issue it waits on has merged (or it
|
|
54
|
+
waits on nothing). Keep at most K lanes running. Whenever a lane is free, start the highest-priority ready
|
|
55
|
+
issue right away (priority label first, then the smaller size). Never hold a ready issue back until other
|
|
56
|
+
lanes finish.
|
|
54
57
|
|
|
55
58
|
- **Shell lane (default, when `forgedock` is on PATH).** One `Bash` call per issue with
|
|
56
59
|
`run_in_background: true`:
|
package/docs/OUTCOME-CODES.md
CHANGED
|
@@ -43,6 +43,12 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
|
|
|
43
43
|
| `dependency.unreadable` | The issue body or a declared dependency could not be read, so the run was refused (fail closed) | engine | Failed or unparsable `gh api` read |
|
|
44
44
|
| `invalid` | Issue is invalid or in a divergent closed state | investigate, engine | `INVESTIGATION:INVALID` marker, or live snapshot |
|
|
45
45
|
| `decomposed` | Issue was decomposed into sub-issues | investigate, decompose | `FORGE:DECOMPOSED` or the decomposition assessment |
|
|
46
|
+
| `builder.blocked` | Core work-on: the builder reported it cannot proceed without a human decision (design, credentials, infra) | lean | `result.json` status `blocked` |
|
|
47
|
+
| `builder.no_evidence` | Core work-on: the builder reported invalid/already-fixed without file:line evidence, so the issue was not closed | lean | `result.json` with empty `evidence` |
|
|
48
|
+
| `builder.no_commit` | Core work-on: the builder made no commit for a fix | lean | No changed files after the builder session |
|
|
49
|
+
| `review.failed` | Core work-on: the fix/regression review failed twice (or could not resume the builder) | lean | Reviewer verdict FAIL |
|
|
50
|
+
| `review.no_verdict` | Core work-on: the reviewer returned no parseable verdict twice | lean | Unparsable reviewer output |
|
|
51
|
+
| `merge.head_mismatch` | Core work-on: CI passed for a head other than the reviewed head; merge refused | lean | Gate head differs from the reviewed head |
|
|
46
52
|
| `completed` | Non-failure terminal (merged, phase-complete, investigation) | engine | Terminal reason |
|
|
47
53
|
| `awaiting_merge` | Clean result held for a merge decision | remediate | Terminal reason `awaiting-merge` |
|
|
48
54
|
| `unclassified` | Fallback for an absent or unknown code | engine | The raw reason is kept alongside |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgedock",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.1",
|
|
4
4
|
"description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forgedock": "bin/forgedock.mjs"
|