forgedock 1.40.0 → 1.42.0
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 +6 -3
- package/bin/engine-cli.mjs +40 -4
- package/bin/forgedock.mjs +22 -1
- package/bin/lean/batch.test.mjs +33 -1
- package/bin/lean/capwait.mjs +36 -0
- package/bin/lean/capwait.test.mjs +48 -0
- package/bin/lean/conformance.mjs +150 -2
- package/bin/lean/conformance.test.mjs +126 -0
- package/bin/lean/depcache.mjs +10 -2
- package/bin/lean/depcache.test.mjs +22 -17
- package/bin/lean/execution.mjs +21 -5
- package/bin/lean/execution.test.mjs +7 -1
- package/bin/lean/heavy.mjs +22 -1
- package/bin/lean/lanecheck.mjs +129 -0
- package/bin/lean/lanecheck.test.mjs +118 -0
- package/bin/lean/lanelock.mjs +31 -0
- package/bin/lean/lean.mjs +102 -30
- package/bin/lean/lean.test.mjs +173 -3
- package/bin/lean/orchestrate.mjs +42 -7
- package/bin/lean/phaseledger.mjs +116 -0
- package/bin/lean/phaseledger.test.mjs +72 -0
- package/bin/lean/prodwatch.mjs +12 -1
- package/bin/lean/prodwatch.test.mjs +32 -3
- package/bin/lean/prompts.mjs +19 -0
- package/bin/lean/render.mjs +10 -2
- package/bin/lean/sandbox-provider.contract.test.mjs +48 -0
- package/bin/lean/sandbox-remote.mjs +8 -1
- package/bin/lean/schedd.mjs +5 -1
- package/bin/lean/selftest-gate.mjs +46 -0
- package/bin/lean/selftest-gate.test.mjs +40 -0
- package/bin/lean/selftest-live.mjs +248 -0
- package/bin/lean/selftest-live.test.mjs +154 -0
- package/bin/lean/selftest-nightly.mjs +81 -0
- package/bin/lean/selftest-nightly.test.mjs +84 -0
- package/bin/lean/selftest-static.mjs +81 -0
- package/bin/lean/selftest-static.test.mjs +32 -0
- package/bin/pool/agent.mjs +16 -5
- package/bin/pool/router.mjs +17 -1
- package/bin/pool/warm.mjs +80 -0
- package/bin/review-staging.mjs +28 -6
- package/bin/stats/aggregate.mjs +2 -1
- package/bin/stats/efficiency.mjs +3 -1
- package/bin/stats/efficiency.test.mjs +4 -1
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/local-source.mjs +1 -0
- package/bin/stats/render.mjs +2 -0
- package/commands/deploy-pr.md +3 -3
- package/commands/review-pr-staging.md +2 -2
- package/commands/work-on/build.md +2 -2
- package/docs/CONFIG.md +33 -0
- package/docs/OUTCOME-CODES.md +1 -1
- package/docs/PROD-WATCH.md +1 -1
- package/docs/RELEASE-NOTES.md +10 -0
- package/docs/SELFTEST.md +34 -0
- package/docs/eval/conformance-labels-v1.json +13 -0
- package/package.json +1 -1
- package/pool/image/Dockerfile +2 -1
- package/pool/image/entrypoint.sh +44 -11
- package/pool/image/warm-refresh.sh +14 -0
- package/scripts/conformance-calibrate.mjs +132 -0
- package/scripts/file-deferred-followups.sh +21 -8
- package/scripts/file-deferred-followups.test.sh +27 -6
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Warm sandboxes (#4912): the agent keeps N sandboxes per repo started and prepared (checkout at the base ref, dependency
|
|
9
|
+
* cache primed; the image's warm mode, CS_WARM=1). A container's env is fixed at `docker run`, so a claim hands the lane
|
|
10
|
+
* over through a mode-0600 claim file in the mounted /claim dir (lane env, command, ref); the sandbox reads it once,
|
|
11
|
+
* deletes it, refreshes the checkout to the lane's ref with an incremental fetch (warm-refresh.sh) and execs the command.
|
|
12
|
+
* The size comes from the launch spec (`spec.warm`, the lane's `execution.warm_sandboxes`); 0 keeps none.
|
|
13
|
+
* `launch`, `destroy` and `view` are the agent's own, so warm sandboxes are ordinary records (capacity, lease, reaper).
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_WARM = 8;
|
|
16
|
+
const keyOf = (spec) => String(spec?.cache?.key ?? spec?.repo?.slug ?? "default").toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 60);
|
|
17
|
+
|
|
18
|
+
export function makeWarm({ store, sb, own, now, launch, destroy, view, log = console }) {
|
|
19
|
+
const wanted = () => (store.data.warm ??= {});
|
|
20
|
+
const recs = (key) => Object.values(sb()).filter((r) => r.warm && (key === undefined || r.warm === key));
|
|
21
|
+
const isReady = (r) => r.state === "running" && existsSync(join(r.dir, "out", "warm-ready"));
|
|
22
|
+
/** The pool entry for a launch spec: n warm sandboxes for the spec's repo (0 removes it). */
|
|
23
|
+
function configure({ spec, cpus, mem_gb, limit = null, n }) {
|
|
24
|
+
const key = keyOf(spec), want = Math.min(MAX_WARM, Number.isInteger(n) ? n : 0);
|
|
25
|
+
if (want <= 0) { delete wanted()[key]; store.save(); return { key, n: 0 }; }
|
|
26
|
+
wanted()[key] = { n: want, cpus, mem_gb, limit, spec: { image: spec.image ?? null, repo: spec.repo ?? null, cache: spec.cache ?? null, docker: Boolean(spec.docker) } };
|
|
27
|
+
store.save();
|
|
28
|
+
return { key, n: want };
|
|
29
|
+
}
|
|
30
|
+
const list = () => recs().map((r) => ({ id: r.id, key: r.warm, state: r.state, ready: isReady(r), cpus: r.cpus, mem_gb: r.mem_gb }));
|
|
31
|
+
/** Start sandboxes until each repo has its n (ready or still preparing). Refused launches (capacity) are retried on the next call. */
|
|
32
|
+
async function replenish() {
|
|
33
|
+
for (const [key, w] of Object.entries(wanted())) {
|
|
34
|
+
for (let have = recs(key).length; have < w.n; have++) {
|
|
35
|
+
const id = `warm-${key}-${randomBytes(3).toString("hex")}`;
|
|
36
|
+
const spec = { id, ...w.spec, env: { CS_WARM: "1" }, command: ["true"], claim_dir: true };
|
|
37
|
+
const r = await launch({ spec, cpus: w.cpus, mem_gb: w.mem_gb, limit: w.limit });
|
|
38
|
+
if (r.status !== 201) break;
|
|
39
|
+
const rec = own(id);
|
|
40
|
+
if (!rec) break;
|
|
41
|
+
if (rec.state === "exited") { await destroy(id, "warm launch failed"); break; }
|
|
42
|
+
rec.warm = key;
|
|
43
|
+
rec.createdWarmAt = now();
|
|
44
|
+
store.save();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Claim a ready warm sandbox of the same repo and size for the lane `spec`; 404 when none is ready. */
|
|
49
|
+
async function claim({ spec, cpus, mem_gb, limit = null }) {
|
|
50
|
+
// #4936: keep the burst limit the router sends with the claim, or replenished sandboxes lose lane_cpus/lane_mem caps
|
|
51
|
+
if (Number.isInteger(spec?.warm)) configure({ spec, cpus, mem_gb, limit, n: spec.warm });
|
|
52
|
+
const known = own(spec?.id);
|
|
53
|
+
if (known) { known.lease = now(); store.save(); return { status: 200, body: view(known) }; }
|
|
54
|
+
const rec = recs(keyOf(spec)).find((r) => isReady(r) && r.cpus === cpus && r.mem_gb === mem_gb);
|
|
55
|
+
if (!rec) return { status: 404, body: { error: "no warm sandbox ready" } };
|
|
56
|
+
const dir = join(rec.dir, "claim");
|
|
57
|
+
mkdirSync(dir, { recursive: true });
|
|
58
|
+
const tmp = join(dir, ".claim.tmp");
|
|
59
|
+
writeFileSync(tmp, JSON.stringify({ env: spec.env ?? {}, command: (spec.command ?? []).map(String), ref: spec.repo?.ref ?? null }), { mode: 0o600 });
|
|
60
|
+
renameSync(tmp, join(dir, "claim.json"));
|
|
61
|
+
// The lane id answers for the warm record (the container's baked cred/relay URLs keep the warm id).
|
|
62
|
+
Object.assign(rec, { alias: spec.id, issue: spec.issue ?? null, batch: spec.batch ?? null, result_file: spec.result_file ?? null, startedAt: now(), lease: now() });
|
|
63
|
+
delete rec.warm;
|
|
64
|
+
store.save();
|
|
65
|
+
replenish().catch((e) => log.warn?.(`warm replenish: ${e.message}`));
|
|
66
|
+
return { status: 200, body: view(rec) };
|
|
67
|
+
}
|
|
68
|
+
/** Reaper hook: unclaimed warm sandboxes keep their own lease; dead or over-age ones are replaced. */
|
|
69
|
+
async function maintain(maxLifeMs) {
|
|
70
|
+
for (const r of recs()) {
|
|
71
|
+
if (r.state === "exited") await destroy(r.id, "warm sandbox exited");
|
|
72
|
+
else if (now() - r.createdWarmAt > maxLifeMs) await destroy(r.id, "warm max lifetime");
|
|
73
|
+
else r.lease = now();
|
|
74
|
+
}
|
|
75
|
+
await replenish();
|
|
76
|
+
}
|
|
77
|
+
/** The record answering to a claimed lane id. */
|
|
78
|
+
const byAlias = (id) => (typeof id === "string" ? Object.values(sb()).find((r) => r.alias === id) : undefined);
|
|
79
|
+
return { configure, list, replenish, claim, maintain, byAlias, keyOf };
|
|
80
|
+
}
|
package/bin/review-staging.mjs
CHANGED
|
@@ -26,12 +26,15 @@ import * as nodeFs from "node:fs";
|
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { parseForgeYaml } from "./forge-utils.mjs";
|
|
29
|
+
import { selftestGate, readMarkerComments } from "./lean/selftest-gate.mjs";
|
|
29
30
|
|
|
30
31
|
const INSTALL_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
31
32
|
const USAGE = "usage: forgedock review-staging [<PR number|PR URL>] [--thorough | --delta <from-sha>] [--json]\n" +
|
|
32
33
|
" forgedock review-staging [<PR>] --finalize --test-gate-output <file> [--notes-dropped <n>] [--json]\n" +
|
|
33
34
|
" forgedock review-staging [<PR>] --summary [--filed <issue>]... [--json]";
|
|
34
35
|
const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
36
|
+
/** selftest.sandbox_repo turns the release gate on only when it names a repo (`null`, `~`, `false` or prose leave it off). */
|
|
37
|
+
const SANDBOX_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
35
38
|
/** Persona file / catalog keys per canonical marker domain (review-pr-agents.md routing table). */
|
|
36
39
|
const INDEX_KEY = { auth: "auth", billing: "billing", database: "database", api: "api", frontend: "frontend", infra: "infrastructure" };
|
|
37
40
|
const CONFIG_DOMAIN = { scraper: "scraping" };
|
|
@@ -137,7 +140,7 @@ function readConfig(io) {
|
|
|
137
140
|
try { cfg = parseForgeYaml(io.readFile(path)) ?? {}; } catch { /* absent config: owner/repo check below fails */ }
|
|
138
141
|
const owner = cfg.project?.owner, repo = cfg.project?.repo;
|
|
139
142
|
if (!owner || !repo) throw new Stop(1, [`review-staging: project.owner/project.repo not set in ${path}`]);
|
|
140
|
-
return { ghRepo: `${owner}/${repo}`, defaultBranch: cfg.branches?.default || "main", stagingBranch: cfg.branches?.staging || "staging", configPath: path };
|
|
143
|
+
return { ghRepo: `${owner}/${repo}`, defaultBranch: cfg.branches?.default || "main", stagingBranch: cfg.branches?.staging || "staging", configPath: path, sandboxRepo: SANDBOX_RE.test(String(cfg.selftest?.sandbox_repo ?? "").trim()) ? String(cfg.selftest.sandbox_repo).trim() : "", sandboxRaw: String(cfg.selftest?.sandbox_repo ?? "").trim() };
|
|
141
144
|
}
|
|
142
145
|
|
|
143
146
|
/** Same derivation as commands/review-pr-staging.md Phase -1 (and scripts/lib/fence-bootstrap.sh). */
|
|
@@ -223,6 +226,14 @@ async function main(argv, io) {
|
|
|
223
226
|
const head = await headSha(io, c, pr);
|
|
224
227
|
if (a.delta && a.delta === head) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: --delta ${head} is the current head of PR #${pr}: nothing changed since that pass, nothing to review`]);
|
|
225
228
|
const sha7 = head.slice(0, 7);
|
|
229
|
+
// #4865: with selftest.sandbox_repo set, the release needs a passing live selftest for exactly this head (fail closed).
|
|
230
|
+
// Without a sandbox repo there is nothing the harness can run against, so the gate is skipped and says so.
|
|
231
|
+
if (c.sandboxRepo) {
|
|
232
|
+
let marks;
|
|
233
|
+
try { marks = await readMarkerComments(io.gh, c.ghRepo, pr); } catch (e) { throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: could not read the FORGE:SELFTEST markers of PR #${pr} (${String(e?.message ?? e).split("\n")[0].slice(0, 200)}); nothing reviewed (fail closed)`]); }
|
|
234
|
+
const selftest = selftestGate(marks, head);
|
|
235
|
+
if (!selftest.ok) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: ${selftest.reason}; nothing reviewed (fail closed)`]);
|
|
236
|
+
} else io.err(`SELFTEST: skipped (selftest.sandbox_repo ${c.sandboxRaw ? `"${c.sandboxRaw.slice(0, 80)}" is not an owner/repo` : "not set"} in forge.yaml; the release does not require a FORGE:SELFTEST marker)`);
|
|
226
237
|
let priorRoutes = 0;
|
|
227
238
|
try {
|
|
228
239
|
const out = await io.gh(["api", "--paginate", `repos/${c.ghRepo}/issues/${pr}/comments`, "--jq", '[.[] | select(.body | test("FORGE:REVIEW_ROUTE"))] | length']);
|
|
@@ -454,9 +465,19 @@ const TEST_GATE_NOTE = {
|
|
|
454
465
|
BLOCK: (r) => `> **Test Gate: BLOCK (override/advisory)**: Runtime failures were detected but deploy is proceeding. Reason: ${r}. Filed \`test-failure\` issues track the failures.`,
|
|
455
466
|
};
|
|
456
467
|
|
|
468
|
+
/** Checks that never count as CI failures: each only reports this review's own gate marker or the lean gate summary. */
|
|
469
|
+
export const CI_EXCLUDED_CHECKS = ["forgedock/verify", "forge/deploy-gate", "Check for FORGE gate markers"];
|
|
470
|
+
|
|
471
|
+
const parseChecks = (out) => { try { const j = JSON.parse(out); return Array.isArray(j) ? j : null; } catch { return null; } };
|
|
472
|
+
|
|
473
|
+
/** Failing (fail/cancel) checks outside CI_EXCLUDED_CHECKS. */
|
|
474
|
+
export function countFailingChecks(checks) {
|
|
475
|
+
return checks.filter((k) => !CI_EXCLUDED_CHECKS.includes(k?.name) && (k?.bucket === "fail" || k?.bucket === "cancel")).length;
|
|
476
|
+
}
|
|
477
|
+
|
|
457
478
|
/** Phase 8 verdict from the deterministic inputs (CI, test gate, CONFIRMED CRITICAL/HIGH). */
|
|
458
479
|
export function stagingVerdict({ ciFailing, testGateInvalid, confirmedCritical, confirmedHigh }) {
|
|
459
|
-
if (ciFailing > 0) return { verdict: "BLOCK DEPLOY", why: `CI failing (${ciFailing} required check(s) red; forgedock/verify
|
|
480
|
+
if (ciFailing > 0) return { verdict: "BLOCK DEPLOY", why: `CI failing (${ciFailing} required check(s) red; forgedock/verify, forge/deploy-gate and the gate-marker check excluded)` };
|
|
460
481
|
if (testGateInvalid) return { verdict: "BLOCK DEPLOY", why: "test-gate result missing/invalid (fail closed)" };
|
|
461
482
|
if (confirmedCritical > 0) return { verdict: "BLOCK DEPLOY", why: `${confirmedCritical} CONFIRMED CRITICAL finding(s)` };
|
|
462
483
|
if (confirmedHigh > 0) return { verdict: "NEEDS FIXES FIRST", why: `${confirmedHigh} CONFIRMED HIGH finding(s)` };
|
|
@@ -472,10 +493,11 @@ async function summary(a, io) {
|
|
|
472
493
|
const tg = testGateResult(io, sd, pr);
|
|
473
494
|
|
|
474
495
|
// Phase 1E read: forgedock/verify is excluded; an unreadable result counts as failing (fail closed). forge/deploy-gate
|
|
475
|
-
//
|
|
496
|
+
// and the gate-marker check are excluded too: they report this review's own marker, so an earlier GATE_FAILURE must not block the re-run that clears it.
|
|
476
497
|
let ciFailing = 1;
|
|
477
|
-
const ci = await io.run("gh", ["pr", "checks", String(pr), "-R", c.ghRepo, "--json", "name,bucket"
|
|
478
|
-
|
|
498
|
+
const ci = await io.run("gh", ["pr", "checks", String(pr), "-R", c.ghRepo, "--json", "name,bucket"], { env, cwd: io.cwd });
|
|
499
|
+
const checks = ci.code === 0 ? parseChecks(ci.stdout) : null;
|
|
500
|
+
if (checks) ciFailing = countFailingChecks(checks);
|
|
479
501
|
|
|
480
502
|
const v = stagingVerdict({ ciFailing, testGateInvalid: tg.invalid, confirmedCritical: st.confirmedCritical, confirmedHigh: st.confirmedHigh });
|
|
481
503
|
const pass = v.verdict === "APPROVE FOR DEPLOY";
|
|
@@ -501,7 +523,7 @@ async function summary(a, io) {
|
|
|
501
523
|
"### Risk Matrix",
|
|
502
524
|
"| Domain | Result | Notes |",
|
|
503
525
|
"|--------|--------|-------|",
|
|
504
|
-
`| CI | ${ciFailing > 0 ? "FAIL" : "PASS"} | ${ciFailing} failing required check(s), forgedock/verify
|
|
526
|
+
`| CI | ${ciFailing > 0 ? "FAIL" : "PASS"} | ${ciFailing} failing required check(s), forgedock/verify, forge/deploy-gate and the gate-marker check excluded |`,
|
|
505
527
|
`| Findings | ${st.confirmedCritical} CRITICAL / ${st.confirmedHigh} HIGH (CONFIRMED) | per-domain verdicts are in the FORGE:REVIEW-AGENT comments |`,
|
|
506
528
|
`| **Test Gate** | \`${tg.verdict}\` | \`${tg.reason || "Phase 6.5 not run"}\` |`,
|
|
507
529
|
"",
|
package/bin/stats/aggregate.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { REWORK_TRIGGERS, firstPassOf } from "../lean/fixround.mjs";
|
|
20
|
+
import { summarizePhases } from "../lean/phaseledger.mjs";
|
|
20
21
|
|
|
21
22
|
export const DEFAULT_MIN_N = 10;
|
|
22
23
|
export const UNKNOWN = "unknown";
|
|
@@ -98,7 +99,7 @@ function timeSection(runs) {
|
|
|
98
99
|
if (ms.length) phases[id] = { n: ms.length, p50: percentile(ms, 50), p90: percentile(ms, 90) };
|
|
99
100
|
}
|
|
100
101
|
const wall = runs.map((r) => r.wallMs).filter((x) => isNum(x) && x >= 0);
|
|
101
|
-
return { phases, wall: { n: wall.length, p50: percentile(wall, 50), p90: percentile(wall, 90) } };
|
|
102
|
+
return { phases, wall: { n: wall.length, p50: percentile(wall, 50), p90: percentile(wall, 90) }, ledger: summarizePhases(runs.map((r) => r.ledger)) }; // #4908
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
function flowSection(runs) {
|
package/bin/stats/efficiency.mjs
CHANGED
|
@@ -214,7 +214,8 @@ export function efficiencyMetrics({ entries = [], logs = new Map(), issues = nul
|
|
|
214
214
|
quality[t] = group(xs);
|
|
215
215
|
}
|
|
216
216
|
acceptance = { pass: 0, fail: 0, pending: 0, unprobed: 0 };
|
|
217
|
-
|
|
217
|
+
acceptance.unprobed_issues = []; // #4908: which merged issues declare no outcome probe
|
|
218
|
+
for (const m of merged) if (m.info) { const k = acceptanceOf(m.info.comments, now); acceptance[k]++; if (k === "unprobed") acceptance.unprobed_issues.push(m.issue); }
|
|
218
219
|
acceptance.probed = acceptance.pass + acceptance.fail + acceptance.pending;
|
|
219
220
|
acceptance.pass_rate = r(per(acceptance.pass, acceptance.pass + acceptance.fail));
|
|
220
221
|
}
|
|
@@ -394,6 +395,7 @@ export function formatEfficiencyLines(m, label = "") {
|
|
|
394
395
|
const a = m.acceptance;
|
|
395
396
|
lines.push(a ? `acceptance (7d after promotion): pass ${a.pass}/${a.pass + a.fail}${a.pass_rate == null ? "" : ` (${Math.round(a.pass_rate * 100)}%)`}, pending ${a.pending}, unprobed ${a.unprobed}`
|
|
396
397
|
: "acceptance (7d after promotion): n/a");
|
|
398
|
+
if (a) lines.push(`unprobed merged issues (no acceptance probe declared): ${a.unprobed_issues?.length ? a.unprobed_issues.map((n) => `#${n}`).join(", ") : "none"}`); // #4908
|
|
397
399
|
lines.push(`tools: calls/turn p50 ${f(m.tool_calls_per_turn_p50)}, re-reads (>=3x same path)/merge ${f(m.rereads_per_merge)}`); // #4410
|
|
398
400
|
const ln = m.lanes; // #4452
|
|
399
401
|
lines.push(ln ? `lanes: ${LANE_SHAPES.map((k) => `${k} ${ln[k]?.n ?? 0} (${usd(ln[k]?.cost_usd ?? 0)})`).join(", ")}; upgrade rate ${ln.upgrade_rate == null ? "n/a"
|
|
@@ -112,7 +112,10 @@ describe("#4402 efficiencyMetrics", () => {
|
|
|
112
112
|
assert.equal(m.quality.full.n, 1);
|
|
113
113
|
assert.equal(m.quality.none.n, 1);
|
|
114
114
|
assert.equal(m.quality.partial.n, 0);
|
|
115
|
-
assert.deepEqual(m.acceptance, { pass: 1, fail: 0, pending: 0, unprobed: 1, probed: 1, pass_rate: 1 });
|
|
115
|
+
assert.deepEqual(m.acceptance, { pass: 1, fail: 0, pending: 0, unprobed: 1, unprobed_issues: [2], probed: 1, pass_rate: 1 });
|
|
116
|
+
});
|
|
117
|
+
it("lists merged issues with no acceptance probe declared (#4908)", () => {
|
|
118
|
+
assert.ok(formatEfficiencyLines(m).includes("unprobed merged issues (no acceptance probe declared): #2"));
|
|
116
119
|
});
|
|
117
120
|
it("renders n/a, never NaN, for an empty cohort", () => {
|
|
118
121
|
const lines = formatEfficiencyLines(efficiencyMetrics({}));
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { execFile } from "node:child_process";
|
|
13
13
|
import { promisify } from "node:util";
|
|
14
14
|
import { emptyCache, loadCache, saveCache } from "./cache.mjs";
|
|
15
|
+
import { unflattenPhases } from "../lean/phaseledger.mjs";
|
|
15
16
|
|
|
16
17
|
export const MARKER_RE = /^<!-- FORGE:TELEMETRY v1( part=(\d+))? -->/;
|
|
17
18
|
const OWNER_REPO_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
@@ -82,7 +83,7 @@ export function runsFromRecords(records) {
|
|
|
82
83
|
if (!run._first || r.ts < run._first) run._first = r.ts;
|
|
83
84
|
if (r.experiment != null && r.arm != null) { run.experiment = String(r.experiment); run.arm = String(r.arm); } // #4629
|
|
84
85
|
if (r.event === "run_terminal") {
|
|
85
|
-
run.finished = true; run.reason = r.reason ?? null; run.code = r.code ?? null; run.endTs = r.ts ?? null; run.wallMs = num(r.wall_ms);
|
|
86
|
+
run.finished = true; run.reason = r.reason ?? null; run.code = r.code ?? null; run.endTs = r.ts ?? null; run.wallMs = num(r.wall_ms); run.ledger = unflattenPhases(r); // #4908
|
|
86
87
|
continue;
|
|
87
88
|
}
|
|
88
89
|
const id = r.phase ?? "other";
|
|
@@ -69,6 +69,7 @@ export function summariseLog(events, fallbackIssue = null) {
|
|
|
69
69
|
remediated: ev.some((e) => e.event === "PHASE_COMMIT" && e.phase === "remediate"),
|
|
70
70
|
startTs, endTs, wallMs: finished && !Number.isNaN(a) && !Number.isNaN(b) ? Math.max(0, b - a) : null,
|
|
71
71
|
phases,
|
|
72
|
+
ledger: finished && ev[t].phases && typeof ev[t].phases === "object" ? ev[t].phases : {}, // #4908: lane phase ledger (wall time per phase)
|
|
72
73
|
contextPack,
|
|
73
74
|
// #4362: builder attempts in log order with their trigger (null before the field existed)
|
|
74
75
|
builderAttempts: builderCalls.map((e) => ({ attempt: e.attempt ?? null, trigger: e.trigger ?? null, restart_of: e.restart_of ?? null, cost_usd: Number.isFinite(costOf(e)) ? costOf(e) : null })),
|
package/bin/stats/render.mjs
CHANGED
|
@@ -37,6 +37,8 @@ function sections(g) {
|
|
|
37
37
|
const t = g.time;
|
|
38
38
|
const phaseRows = Object.entries(t.phases).map(([p, v]) => [p, String(v.n), dur(v.p50), dur(v.p90)]);
|
|
39
39
|
out.push("### Time", ...table(["phase", "n", "p50", "p90"], [...phaseRows, ["wall per issue", String(t.wall.n), dur(t.wall.p50), dur(t.wall.p90)]]), "");
|
|
40
|
+
const L = t.ledger; // #4908: where lane wall time goes (engine phases)
|
|
41
|
+
out.push("### Lane wall time by phase", ...(L?.n ? [...table(["phase", "n", "p50", "p90"], Object.entries(L.phases).map(([p, v]) => [p, String(v.n), dur(v.p50), dur(v.p90)])), `Largest: ${L.largest.phase} (${Math.round(L.largest.share * 100)}% of ledger time)`] : [NA]), "");
|
|
40
42
|
const f = g.flow;
|
|
41
43
|
out.push("### Flow", ...table(["metric", "value"], [
|
|
42
44
|
["merged", pct(f.mergedPct)], ["needs-human", pct(f.needsHumanPct)], ["remediation rate", pct(f.remediationPct)],
|
package/commands/deploy-pr.md
CHANGED
|
@@ -370,9 +370,9 @@ else
|
|
|
370
370
|
# Fail closed: valid fields only (`status`/`conclusion` do not exist on gh pr checks --json; the
|
|
371
371
|
# old query errored and defaulted to 0 = "no failures"). An unreadable result counts as failing.
|
|
372
372
|
# forgedock/verify (the lean engine's report-only gate summary) is not a code failure: ignored, as wait-ci-green.sh does.
|
|
373
|
-
# forge/deploy-gate (the staging review's marker status)
|
|
373
|
+
# forge/deploy-gate and "Check for FORGE gate markers" (the staging review's marker status and its workflow job) are not fixable by /fix-ci: ignored here, still waited on in Phase 5.
|
|
374
374
|
CURRENT_CI_STATUS=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,bucket \
|
|
375
|
-
--jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
375
|
+
--jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate" and .name != "Check for FORGE gate markers") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
376
376
|
|
|
377
377
|
if [ "$CURRENT_CI_STATUS" -eq 0 ]; then
|
|
378
378
|
echo "CI gate: PASSED (no failing checks)"
|
|
@@ -394,7 +394,7 @@ else
|
|
|
394
394
|
|
|
395
395
|
# Re-check CI status after fix attempt (same fail-closed query as above)
|
|
396
396
|
FAILING_AFTER=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,bucket \
|
|
397
|
-
--jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
397
|
+
--jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate" and .name != "Check for FORGE gate markers") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
398
398
|
|
|
399
399
|
if [ "$FAILING_AFTER" -eq 0 ]; then
|
|
400
400
|
echo "CI gate: PASSED after $CI_ITER iteration(s)"
|
|
@@ -212,10 +212,10 @@ gh pr checks "$PR_NUMBER" -R "$GH_REPO" 2>&1
|
|
|
212
212
|
# forgedock/verify (the lean engine's report-only gate summary) is not a code failure: ignored, as wait-ci-green.sh does.
|
|
213
213
|
# Fail closed: an unreadable result counts as failing.
|
|
214
214
|
CI_FAILING=$(gh pr checks "$PR_NUMBER" -R "$GH_REPO" --json name,bucket \
|
|
215
|
-
--jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
215
|
+
--jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate" and .name != "Check for FORGE gate markers") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
216
216
|
echo "CI_FAILING: $CI_FAILING"
|
|
217
217
|
```
|
|
218
|
-
`CI_FAILING` > 0 → BLOCK DEPLOY (unless autofixed in Phase 1F). A red `forgedock/verify` alone does not block, and neither
|
|
218
|
+
`CI_FAILING` > 0 → BLOCK DEPLOY (unless autofixed in Phase 1F). A red `forgedock/verify` alone does not block, and neither do `forge/deploy-gate` or `Check for FORGE gate markers`: they report this review's own gate marker, so an earlier `GATE_FAILURE` never blocks the re-run that clears it.
|
|
219
219
|
|
|
220
220
|
### Phase 1F: CI Autofix
|
|
221
221
|
|
|
@@ -376,7 +376,7 @@ Every item in the contract's `### Out of Scope` section must carry exactly one d
|
|
|
376
376
|
- **Known-flaw rule**: a contract that keeps a known-flawed design (a design the investigation or architect plan identified as flawed, a fail-open caller, an unsound threshold) must state the flaw and list it as a `deferred → #N` item with a linked follow-up. It may not hide behind `accepted-risk` or free text.
|
|
377
377
|
- Free-text exclusions without a disposition are invalid. `->` is accepted for `→`.
|
|
378
378
|
|
|
379
|
-
**Fold, then batch** <!-- Added: forge#3843 -->: before filing anything, fold small siblings into scope. A candidate deferred item that is the same mechanical change at a sibling path and fits the remaining scope budget (a small fraction of the plan's own estimate: at most 20% of the current `ESTIMATED_DIFF_LINES`, or 200 lines when no estimate exists) goes into the contract's Deliverables, not Out of Scope; count it as `folded`. Defer only genuinely separate work (different module, design or owner). All remaining deferred items of this build go into ONE follow-up issue per class (engineering | post-deploy | external | ops, each labelled `followup:<class>` and carrying the parent's milestone, #4664) with a checklist; `scripts/file-deferred-followups.sh` appends to an existing trusted follow-up for the same parent (exact path match; a closed one is reopened) or creates one, then records a `<!-- FORGE:DEFERRED_ISSUE: #M -->` claim marker on the parent.
|
|
379
|
+
**Fold, then batch** <!-- Added: forge#3843 -->: before filing anything, fold small siblings into scope. A candidate deferred item that is the same mechanical change at a sibling path and fits the remaining scope budget (a small fraction of the plan's own estimate: at most 20% of the current `ESTIMATED_DIFF_LINES`, or 200 lines when no estimate exists) goes into the contract's Deliverables, not Out of Scope; count it as `folded`. Defer only genuinely separate work (different module, design or owner). All remaining deferred items of this build go into ONE follow-up issue per class (engineering | post-deploy | external | ops, each labelled `followup:<class>` and carrying the parent's milestone, #4664) with a checklist; `scripts/file-deferred-followups.sh` appends to an existing trusted follow-up for the same parent (exact path match; a closed one is reopened only if completed, not resolved by a person, and given new items) or creates one, then records a `<!-- FORGE:DEFERRED_ISSUE: #M -->` claim marker on the parent.
|
|
380
380
|
|
|
381
381
|
**File the `deferred` follow-ups** (skip the filing on resume when the contract already exists):
|
|
382
382
|
|
|
@@ -397,7 +397,7 @@ esac
|
|
|
397
397
|
- Only follow-ups by a trusted author (OWNER/MEMBER/COLLABORATOR or Bot; `FORGE_TRAIL_TRUSTED_*` honoured) are reused; an untrusted match, claim comment or issue is ignored.
|
|
398
398
|
- Lookup order: the `FORGE:DEFERRED_ISSUE` claim markers on the parent's REST comments (read-after-write consistent), then a search over all issue states (the search index lags, so it is only a fallback); each candidate is verified by number and the lowest number is canonical. After creating, the script posts the claim and re-checks once: a lower-numbered trusted follow-up from a concurrent build wins, the items move to it and the newer issue is closed as a duplicate. <!-- Added: forge#3606 -->
|
|
399
399
|
- `DEFERRED_LOOKUP=FAILED` or `DEFERRED_FILE=FAILED` (non-zero `DEFERRED_RC`) → Blocked exit with blocker `github-unavailable: could not file or look up deferred follow-ups` (a transient failure follows the retry rule above; no `needs-human` for `github-unavailable:`).
|
|
400
|
-
- Prose tier (script absent): create one issue by hand whose body has the exact `<!-- FORGE:DEFERRED_FROM: #{NUMBER} -->` marker and a `- [ ] \`path\` — why` checklist line per path, after a trusted-author lookup over all issue states (not only open; read the parent's comments for an existing `FORGE:DEFERRED_ISSUE` claim first); post `<!-- FORGE:DEFERRED_ISSUE: #M -->` on the parent after creating; a failed lookup is a Blocked exit.
|
|
400
|
+
- Prose tier (script absent): create one issue by hand whose body has the exact `<!-- FORGE:DEFERRED_FROM: #{NUMBER} -->` marker and a `- [ ] \`path\` — why` checklist line per path, after a trusted-author lookup over all issue states (not only open; a closed tracker is reused only when closed as completed with no person having the last comment, else file a fresh one; read the parent's comments for an existing `FORGE:DEFERRED_ISSUE` claim first); post `<!-- FORGE:DEFERRED_ISSUE: #M -->` on the parent after creating; a failed lookup is a Blocked exit.
|
|
401
401
|
- On resume the lookup reuses the issue named by the `FORGE:DEFERRED_ISSUE` claim (or carrying the marker, open or closed, reopening a closed one) and appends only missing paths, so a retried build never files a duplicate.
|
|
402
402
|
|
|
403
403
|
**Validate the composed section** with `scripts/check-contract-scope.sh validate` (the exact text that will replace `{OUT_OF_SCOPE_ITEMS}`):
|
package/docs/CONFIG.md
CHANGED
|
@@ -58,6 +58,7 @@ If you have both a local `claude` CLI and `ANTHROPIC_API_KEY` set and want to pi
|
|
|
58
58
|
| [`verification`](#verification-optional) | No | Health-check patterns |
|
|
59
59
|
| [`execution`](#execution-optional) | No | `forgedock orchestrate`: run lanes in sandboxes instead of host processes (`mode: local\|remote\|hybrid`, `max_remote`) |
|
|
60
60
|
| [`verify`](#verify-optional) | No | Core work-on: run lane verification on CI runners instead of the host (`mode: local\|ci\|hybrid`) |
|
|
61
|
+
| [`selftest`](#selftest-optional) | No | `forgedock selftest --live`: the disposable sandbox repo and fixture language of the live contract harness |
|
|
61
62
|
| [`billing`](#billing-optional) | No | Enable financial integrity audit phase |
|
|
62
63
|
| [`adaptive_scripts`](#adaptive_scripts-optional) | No | Per-repo script override configuration |
|
|
63
64
|
|
|
@@ -594,6 +595,8 @@ engine:
|
|
|
594
595
|
|
|
595
596
|
| `path` | string | No | `core` (default), `full`, `lean` or `auto`. `core` is the lean work-on (`bin/lean/`): a planning call that writes a frozen contract, an implement call in the same session, one read-only conformance reviewer, GitHub as the register (see `conformance`). `lean` and `auto` are aliases of `core`; `full` runs the legacy multi-phase pipeline and is required for `--retry` and `--wait-lease`. The `--path` flag overrides. `lean_reviewer_model` sets the reviewer model (default `claude-sonnet-5-5`); the builder uses `--model` or the CLI default. `forgedock orchestrate <issues...> [--parallel K] [--lane L]` runs the core per issue (K default 3, lane default `staging`) and honours `**Depends on**` ordering among the given issues. A lane that reports an open dependency pulls it into the batch, unless that dependency is labelled `needs-human` or already ended `needs-human` in the batch: then the issue and its dependents end `skipped (dependency #N needs-human)` without a lane for it (forge#4387). |
|
|
596
597
|
| `conformance` | string | No | Core work-on conformance gate. The builder first writes a contract (requirements quoted verbatim from the issue and maintainers' comments, deliverables, preserve constraints, validate commands), which the engine freezes and posts as `FORGE:CONTRACT`; changes go only through typed amendments (`FORGE:AMENDMENT`). A deterministic precheck (scope, validation, deferrals) and a row-based conformance review follow, and the engine computes the verdict. `shadow` (default): the verdict is posted in `FORGE:REVIEW` and logged (`CONFORMANCE_SHADOW` event) but only what the previous reviewer blocked on still blocks (a requirement found not implemented, a regression), plus the precheck's equivalent: any `conformance.unimplemented` precheck or amendment failure (a validate command that failed or has no recorded result, a deliver requirement the builder reported as not implemented, an invalid amendment; #4160). These block before any reviewer run. Scope creep and omissions are recorded only. A review still incomplete or inconsistent after one targeted re-ask (only the missing ids) ends `needs-human`, or with `review_incomplete: degraded` merges with a `review-degraded` PR label and a tracked follow-up. Accepted deferrals are filed and linked, as in enforce. `scripts/calibration.mjs --shadow` reports the per-repo shadow verdict mix and when to switch to enforce. `enforce`: every failure blocks (one same-session builder resume, then `needs-human` with a `conformance.*`/`review.*` code, see docs/OUTCOME-CODES.md); deferrals the reviewer accepts are filed in one batched follow-up issue (`scripts/file-deferred-followups.sh`) and linked in the PR body and a `FORGE:TRACKED` comment before merge. |
|
|
598
|
+
| `conformance_kinds` | string | No | Per-check-kind gate modes. A flat list `"code=mode, code=mode"`, for example `"conformance.omission=enforce, conformance.guard_broken=shadow"`; mode is `enforce` (blocks) or `shadow` (recorded in `FORGE:REVIEW` and the run log, never blocks). A kind listed here follows its own mode in a shadow and in an enforce lane; every other kind follows `conformance`. A `review.risk_paths` hit still enforces the whole lane. Invalid entries are dropped; absent changes nothing. **Enforcement rule:** enforce a kind only when its measured false-block rate is ~0 over enough blocks; keep the rest in shadow. Measure with `node scripts/conformance-calibrate.mjs --labels docs/eval/conformance-labels-v1.json [--replay <corpus.json>]`, which scores labelled lane verdicts (and an optional replay corpus) and prints precision, recall and false-block rate (false blocks / blocks) per kind, plus the `conformance_kinds` value the data supports (default: at least 3 blocks and 0 false blocks; `--min-blocks`, `--max-false-rate`). A deliver requirement whose test deliverable no executed passing validate runs is listed as unverified with its id (decision line, `REQUIREMENT_UNVERIFIED` event; report only). With `conformance_kinds` set, the lane row carries the failure codes seen (`kinds`) and the batch report adds per-kind would-block shares. |
|
|
599
|
+
| `conformance_mutation` | string | No | `off` (default) or `report` (#4913). `report` reruns each deliver requirement's executed evidence test with the diff's non-test files reverted to the merge base (`git apply -R`, restored from HEAD afterwards, even on error); a test that still passes is vacuous and raises `conformance.mutation_survived` (`REQUIREMENT_MUTATION_SURVIVED` event, decision line). The kind is shadow (recorded only) unless `conformance_kinds` sets `conformance.mutation_survived=enforce`. A dirty tree, failed revert, timeout (124) or missing runner skips the check, never fails it. Capped at 120 s per gate. Judge promotion with `node scripts/conformance-calibrate.mjs --labels <labels.json> --events <run-log>`: it prints the survived-mutation share and the false-survived rate (#4929). |
|
|
597
600
|
| `cli_idle_minutes` | number | No | Core builder idle timeout (#4112): the builder CLI runs with `--output-format stream-json` and is killed (code `cli.idle`) when it shows no stream activity for this many minutes. The run log records the last tool call and the live child processes. Default 15. |
|
|
598
601
|
| `cli_busy_minutes` | number | No | Extended idle window for a core builder (#4405). When the idle timeout is reached but the session has a live heavy step (a heavy-shim step, test runner, linter or type checker child) or a queued or running heavy-queue entry of its own processes, the kill waits until `cli_idle_minutes` + this many minutes of silence. The run log records `CLI_IDLE_EXTENDED` once per silent stretch, and a later `CLI_IDLE_KILL` carries `heavyQueue` ({queued, running}) and `extended: true`. A silent session with no such child is still killed at `cli_idle_minutes`. Default 45 (the heavy queue's 30-minute maximum wait plus the 15-minute idle default). |
|
|
599
602
|
| `cli_max_minutes` | number | No | Absolute ceiling for a core builder session (code `cli.timeout`), replacing the fixed 60-minute kill. A session that keeps making progress is never idle-killed. Default 180. On either kill, uncommitted work is committed as `wip(forge): checkpoint after <reason>` and the lane branch is pushed (with the push retry), the builder retries once from the checkpoint, and a second kill ends `deferred`; a rerun resumes from the branch. |
|
|
@@ -656,6 +659,27 @@ prod_watch:
|
|
|
656
659
|
|
|
657
660
|
---
|
|
658
661
|
|
|
662
|
+
## `selftest` (OPTIONAL)
|
|
663
|
+
|
|
664
|
+
Settings for `forgedock selftest --live` (see [SELFTEST.md](SELFTEST.md)): a harness that drives the real `gh`, git and `orchestrate` against a disposable sandbox repo, and the `FORGE:SELFTEST` result the staging->main review requires.
|
|
665
|
+
|
|
666
|
+
```yaml
|
|
667
|
+
selftest:
|
|
668
|
+
sandbox_repo: acme/forgedock-sandbox # disposable repo; the harness creates and closes issues, PRs and branches in it
|
|
669
|
+
fixture: node # node (default) | python
|
|
670
|
+
nightly_interval_hours: 24 # opt-in: schedd runs `selftest --live` this often (default off)
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
| Key | Type | Required | Description |
|
|
674
|
+
|-----|------|----------|-------------|
|
|
675
|
+
| `sandbox_repo` | string | No | `owner/repo` the live harness runs in. `--repo` overrides it; with neither the command stops. Never point it at a real project. Setting it also turns on the release gate: `forgedock review-staging` then requires a passing `FORGE:SELFTEST` marker for the release PR head; unset, the gate is skipped. |
|
|
676
|
+
| `fixture` | string | No | Toolchain of the two trivial fixture issues: `node` (default) or `python`. `--lang` overrides it. |
|
|
677
|
+
| `nightly_interval_hours` | number | No | Off when unset or 0. When positive, the schedd daemon (host, never GitHub Actions) starts a detached `selftest --live` against `sandbox_repo` once this many hours have passed since the last run of that repo. On failure, including a run that cannot start, it files or edits one issue naming the failed step, in the repo schedd resolves for its working directory. See [SELFTEST.md](SELFTEST.md#nightly-run). |
|
|
678
|
+
|
|
679
|
+
**Commands that use this section**: `forgedock selftest`, `forgedock review-staging` (release gate, only when `sandbox_repo` is set)
|
|
680
|
+
|
|
681
|
+
---
|
|
682
|
+
|
|
659
683
|
## `issue_watch` (OPTIONAL)
|
|
660
684
|
|
|
661
685
|
Named, scoped, capped watches for `forgedock issue-watch` (see [ISSUE-WATCH.md](ISSUE-WATCH.md)). Write `issue_watch:`, then `watches:`, then one block per watch name. The release keys need `mode: release` and at least a `threshold` or `immediate`.
|
|
@@ -832,6 +856,10 @@ stats:
|
|
|
832
856
|
|
|
833
857
|
Each alert line names the event, the rate, the sample size (`count/sample` lanes) and one example reason. When no alert fires, nothing is printed. The flat form `stats.alerts_<key>` also works and wins. `forgedock stats --no-local` skips the alerts.
|
|
834
858
|
|
|
859
|
+
### Phase ledger (#4908)
|
|
860
|
+
|
|
861
|
+
No configuration. Every lane records start and end of the engine phases it reaches, so a lane's wall time is attributed instead of guessed: `queue_wait`, `sandbox_start` (claim, worktree, toolchain), `deps_install`, `validate`, `plan`, `implement`, `precheck`, `review`, `ci_wait`, `base_sync`, `merge`, each with `model_ms` and `usd` where model calls ran in it. A lane's phases sum to its `wall_ms`; `queue_wait` precedes the lane clock and is added by the batch runner. The ledger is the `phases` object on each `log.jsonl` outcome row and on `RUN_TERMINAL`, and `phase_<id>_ms|_model_ms|_usd` fields in `FORGE:TELEMETRY`. The batch report prints a `phases` line (p50/p90 per phase, largest share of wall time, `n/a` without ledger data) and `forgedock stats` prints a `Lane wall time by phase` table. The efficiency lines also list merged issues with no acceptance probe declared (`unprobed merged issues`).
|
|
862
|
+
|
|
835
863
|
## `verify` (OPTIONAL)
|
|
836
864
|
|
|
837
865
|
Verify-by-CI for the core work-on path (forge#4167). It moves a lane's heavy verification (type-checks, test suites, docker test containers) off the host and onto CI runners, and it catches CI-class failures (lock files, suite wiring, hangs, formatting) before the lane merges into staging. Write it as a block-style section: `parseForgeYaml` does not read inline `{ ... }` maps.
|
|
@@ -982,6 +1010,7 @@ execution:
|
|
|
982
1010
|
| `budget_minutes`, `budget_usd` | number | No | The `execution.budget` per batch: once the batch's sandbox minutes / estimated USD (settled lanes plus the running ones' elapsed time) reach the cap, no new remote lane is admitted. Missing, non-numeric or negative means no cap. |
|
|
983
1011
|
| `day_budget_minutes`, `day_budget_usd` | number | No | The same cap per host UTC day, summed over every batch on the host from the host-day store (`execution-budget.json` in `~/.forge/sched/`, or `FORGEDOCK_EXECUTION_BUDGET_FILE`). A corrupt or unreadable store refuses remote lanes while a day cap is set; remove the file to reset it. |
|
|
984
1012
|
| `fallback_local` | boolean | No | `true`: a remote-placed lane runs on the host while the provider reports no agent online (`GET {endpoint}/status -> {"online": N}`, the built-in router answers it with `online`, `total` and per-agent `ok`/`paused`), so a batch completes locally with every pool agent offline or asleep (forge#4547). Default `false`: such lanes defer (`sandbox.capacity`). A missing or failing `/status` is unknown and never triggers it. |
|
|
1013
|
+
| `warm_sandboxes` | integer | No | Warm sandboxes per repo the pool agent keeps ready (checkout at the base ref, dependency cache primed). When `N > 0` the launch spec carries `warm: N` and the lane first calls `POST {endpoint}/sandboxes/claim`; a ready warm sandbox takes the lane (refreshed to the lane's base ref with an incremental fetch before work starts), and when none is ready, the claim fails or the endpoint lacks the route the lane starts cold. Default `0` (off): no claim call and the launch spec is unchanged (forge#4912). |
|
|
985
1014
|
| `unreachable_window_min` | number | No | Remote providers: how long a lane keeps polling (with backoff, up to 30s) after the provider's transport fails (connection refused/reset, `fetch failed`, timeout, 502/503/504) before it ends `deferred` with `sandbox.unreachable` (forge#4672). Inside the window the lane never ends and the sandbox is never torn down; after it the sandbox is kept and adopted on the next attempt or `--resume`; the engine-error breaker does not count it. `adopt` applies the same window. Default: 5 |
|
|
986
1015
|
| `on_budget` | string | No | `stop` (default): running lanes finish and the rest end `held` (`execution budget: ...`) for a later `--resume`. `local`: the rest run as detached host lanes. |
|
|
987
1016
|
| `host_capabilities` | list | No | Host-only capabilities the dispatching host has and a sandbox does not relay (forge#4542): names such as `sops`, `prod_monitor`, `ops_credentials`. A lane whose issue text names one (built-in signals: `.secrets/*.enc.yaml`, `sops`, `ssh *-monitor`, `obs-psql`, `credentials.yaml`; a declared name written out; `host_capability_patterns`) is placed `local` in `remote` and `hybrid` mode with the reason `host capability: <name>`, unless the provider relays it (`relay_commands`, forge#4778; otherwise remote providers relay none). A remote lane that still blocks as `credentials` naming only a declared capability ends `env.capability_remote` (recoverable) and is rerun locally once. |
|
|
@@ -1004,6 +1033,10 @@ execution:
|
|
|
1004
1033
|
|
|
1005
1034
|
**Start failures** (every provider): a sandbox that cannot start ends the lane `needs-human` with code `sandbox.start_failed`, not relaunched and not counted by the engine-error breaker; a capacity error ends it `deferred` (`sandbox.capacity`), requeued after the provider's retry-in (else 60s) and not counted toward `orchestration.max_deferrals`, so a queue deeper than the pool waits instead of being skipped (forge#4528). A pool that serves `GET {endpoint}/status -> {"free": N}` (integer, optional) also caps remote admissions to N per sample, so the extra lanes queue inside the batch instead of bouncing on a 503 (forge#4540); a missing, failing or malformed `/status` is unknown and never blocks admission. See docs/OUTCOME-CODES.md. `forgedock gc --apply --sandboxes` tears a stale remote sandbox down through the same API (the checkout's forge.yaml `execution`).
|
|
1006
1035
|
|
|
1036
|
+
**Cold start** (forge#4909): the dependency cache (`FORGE_DEPCACHE_ROOT`, default `~/.forge/depcache`, keyed by lockfile hash) is hardlinked into lane worktrees; when it sits on another filesystem than the worktree (EXDEV) files are copied instead, so a second lane with the same lockfile hash still records `deps: hit` rather than reinstalling. A lane refused for capacity (`sandbox.capacity`) on a pool that serves `/status -> {"free": N}` is relaunched only once a slot is free, not on the retry-in timer; the wait is recorded as the lane's queue time (`waits.queue`).
|
|
1037
|
+
|
|
1038
|
+
**Warm start** (forge#4912): with `warm_sandboxes: N > 0` the lane asks `POST {endpoint}/sandboxes/claim` (same body as the launch spec; `200`/`201` = claimed, `204`/`404`/`405` = none ready) before a cold `POST /sandboxes`. The built-in router forwards the claim to a pool agent holding a ready warm sandbox of the same repo and size; the agent (`GET /warm` lists them, `POST /warm` sets a repo's size, `POST /warm/claim`) starts `N` sandboxes per repo from the `warm` of the launch specs it sees, hands the lane over through a mode-0600 claim file, and refills the pool. The claimed sandbox runs `git fetch` for the lane's ref and resets to it, so installed dependencies stay. Warm sandboxes count against the agent's reservation, are renewed by the agent itself and replaced at the agent's max lifetime. Capped at 8 per repo.
|
|
1039
|
+
|
|
1007
1040
|
**Hybrid placement** (`mode: hybrid`, forge#4256, `bin/lean/execution.mjs` `placeLane`, no model call). Each lane is placed when it is admitted. The first matching rule wins:
|
|
1008
1041
|
|
|
1009
1042
|
1. `max_remote: 0` → local.
|
package/docs/OUTCOME-CODES.md
CHANGED
|
@@ -107,7 +107,7 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
|
|
|
107
107
|
`forgedock orchestrate` recovers mechanical outcomes inside the batch:
|
|
108
108
|
|
|
109
109
|
- **recoverable**:
|
|
110
|
-
- needs-human with `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.capability_remote`, `contract.invalid_command` or `budget.exceeded`;
|
|
110
|
+
- needs-human with `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.validate_timeout`, `env.capability_remote`, `contract.invalid_command` or `budget.exceeded`;
|
|
111
111
|
- `contract.invalid` when the issue was edited mid-run (`issueEdited`);
|
|
112
112
|
- `ci-pending` on a conflicting PR, recovered as `base.conflict`.
|
|
113
113
|
|
package/docs/PROD-WATCH.md
CHANGED
|
@@ -24,7 +24,7 @@ Config lives in `forge.yaml` under `prod_watch:` (see [CONFIG.md](CONFIG.md#prod
|
|
|
24
24
|
## Safety rules
|
|
25
25
|
|
|
26
26
|
- **Dry run.** `--dry-run` runs one round and prints what it would file. It makes no GitHub write, no LLM call, no page, and no state, lock or metrics write.
|
|
27
|
-
- **No blind filing.** If the issue list cannot be read, nothing is filed.
|
|
27
|
+
- **No blind filing.** If the issue list cannot be read, nothing is filed. Open issues are listed in full (paginated), closed ones only as a recent window for metrics and verify.
|
|
28
28
|
- **Human-only actions** (the classes in the repo's decisions policy, `decisions.human_only`) are never taken or filed. They are collected into one owner digest issue per UTC day, created on the first item and edited as more arrive.
|
|
29
29
|
- **Paging.** Only a P0 incident pages, once per cluster, through `alert_channel` (a command that receives the title and body on stdin). Nothing else pages.
|
|
30
30
|
- **State.** `~/.forge/watch/<repo>/prod-watch/` (or `FORGEDOCK_WATCH_DIR`) holds a lock, `status.json` and a rebuildable `state.json`; GitHub is the register.
|
package/docs/RELEASE-NOTES.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Operator-facing notes for behavior changes that need action or awareness. Newest first.
|
|
4
4
|
|
|
5
|
+
## Unreleased: `forgedock selftest`, opt-in release gate and nightly run; gate-marker check ignored
|
|
6
|
+
|
|
7
|
+
- **New command** `forgedock selftest [--static] | --live [--repo <owner/sandbox>] [--pr N]`. It runs a live contract harness against a disposable sandbox repo, plus a static check of every `gh --json` field list. See [SELFTEST.md](SELFTEST.md).
|
|
8
|
+
- **Opt-in nightly run:** `selftest.nightly_interval_hours` makes the schedd daemon start a detached live selftest on that schedule and file one issue on failure. Restart schedd after upgrading to pick it up.
|
|
9
|
+
- **Gate-marker check ignored:** the `Check for FORGE gate markers` check is no longer read as a CI failure by `review-staging`, `/review-pr-staging` or `/deploy-pr`. It only echoes the latest gate marker, so after a GATE_FAILURE it blocked its own GATE_PASS (#4941).
|
|
10
|
+
|
|
11
|
+
### Live selftest gates the staging->main release review (when a sandbox repo is set)
|
|
12
|
+
|
|
13
|
+
When forge.yaml sets `selftest.sandbox_repo`, `forgedock review-staging` stops with `REVIEW_RESULT: status: BLOCKED` unless the staging->main PR carries a `FORGE:SELFTEST result=pass` comment for its current head commit (a missing, failing, malformed or stale marker refuses). Run `forgedock selftest --live --repo <owner/sandbox> --pr <N>` (sandbox repo or `selftest.sandbox_repo` in forge.yaml, see [CONFIG.md](CONFIG.md#selftest-optional)) after the last push to the release PR; it posts the marker. Without `selftest.sandbox_repo` the gate is skipped (the CLI prints `SELFTEST: skipped` on stderr), so repos without a sandbox are not blocked. Details: [SELFTEST.md](SELFTEST.md).
|
|
14
|
+
|
|
5
15
|
## v1.30.0: environment failures are recoverable, scoped pre-PR checks, decomposed parents can close
|
|
6
16
|
|
|
7
17
|
- **New recoverable codes.** A validate command the host cannot run (missing interpreter, module or executable) ends `env.tool_missing`, and a repro test the harness could not run on head ends `env.repro_unrunnable`. Both were terminal `conformance.unimplemented` before. The batch runner recovers them like `ci.red` (see docs/OUTCOME-CODES.md).
|
package/docs/SELFTEST.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Selftest
|
|
2
|
+
|
|
3
|
+
The engine's unit tests mock `gh`, git and the pool transport, so defects such as #4859 (`authorAssociation` is not a gh field), #4860 (issue body wiped to `FORGE:STATE`), #4817 and #4818 passed CI. `forgedock selftest` checks the real binaries.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
forgedock selftest --static
|
|
7
|
+
forgedock selftest --live [--repo <owner/sandbox>] [--lang node|python] [--pr <staging->main PR>]
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`--live` also runs the static check. The sandbox repo comes from `--repo` or `selftest.sandbox_repo` in forge.yaml ([CONFIG.md](CONFIG.md#selftest-optional)). Run it on the host or the pool, not in GitHub Actions. The exit code is non-zero and the output names the failed step.
|
|
11
|
+
|
|
12
|
+
## Static check
|
|
13
|
+
|
|
14
|
+
Every literal `gh issue|pr view|list --json a,b` field list in non-test `bin/**/*.mjs` must be a subset of the `JSON FIELDS` of `gh <cmd> --help` on this host. A violation is reported as `file:line`.
|
|
15
|
+
|
|
16
|
+
## Live steps
|
|
17
|
+
|
|
18
|
+
1. **create**: the fixture base files, then issues A and B (B has `**Depends on**: #A`), each a trivial change with a test that fails on the old code.
|
|
19
|
+
2. **add**: `orchestrate A --parallel 2`, then `orchestrate --add <id> B`; B must be added (not skipped or unreadable) and wait on A in the DAG.
|
|
20
|
+
3. **body**: after the add, after the resume and while waiting, each issue body must still contain its creation text (`FORGE:STATE` ignored).
|
|
21
|
+
4. **resume**: the orchestrate client is killed and `--resume`d; the live lane of A must be re-adopted, not launched again.
|
|
22
|
+
5. **merge**: both lanes merge; the PRs are `MERGED`, the issues closed with `workflow:merged`, and A merged before B.
|
|
23
|
+
6. **remote**: with `execution.mode` `remote` or `hybrid`, one lane runs remotely; the sandbox engine version must equal the CLI version and the lane must authenticate. Skipped (noted) when no pool is configured.
|
|
24
|
+
7. **cleanup**: always runs, even after a failure: the orchestrate client is stopped; the issues, PRs and branches are closed or deleted.
|
|
25
|
+
|
|
26
|
+
The first failing step stops the run.
|
|
27
|
+
|
|
28
|
+
## Release gate
|
|
29
|
+
|
|
30
|
+
With `--pr N`, the PR head is read before the run (from `--release-repo`, else the current repo), and the result is posted on the PR as `<!-- FORGE:SELFTEST result=pass|fail sha=<head> step=<failed step|none> -->`. When forge.yaml sets `selftest.sandbox_repo`, `forgedock review-staging` reads the newest marker and refuses (`REVIEW_RESULT: status: BLOCKED`, nothing reviewed) unless it is a pass for the PR's current head. Only markers from trusted commenters count (maintainer association, a GitHub App bot, or `FORGE_TRAIL_TRUSTED_LOGINS`), and only when the marker leads the comment. A push after the selftest makes the marker stale: re-run it. The harness refuses a sandbox repo that is the project repo (forge.yaml `project`) or the release repo. Without `selftest.sandbox_repo` the gate is skipped and the CLI prints `SELFTEST: skipped` on stderr.
|
|
31
|
+
|
|
32
|
+
## Nightly run
|
|
33
|
+
|
|
34
|
+
Off by default. Set `selftest.nightly_interval_hours` (see [CONFIG.md](CONFIG.md#selftest-optional)) and the schedd daemon starts `selftest --live --nightly-report <repo>` on the host, as a detached process, once that many hours have passed since that repo's last run (last run time cached per repo in `selftest-nightly-<owner>_<repo>.json` under the sched dir). The daemon never runs the harness itself, so a long run never holds its other sweeps. It never runs on GitHub Actions. A failing run, including one that cannot start (for example no `selftest.sandbox_repo`, step `config`) or a harness crash, files one issue titled `selftest --live failed at step <step> (nightly)` carrying `<!-- FORGE:SELFTEST_NIGHTLY -->`; the next failure edits that open issue instead of filing another. A passing run writes nothing. The issue lives in the repo schedd resolves for the working directory, and the sandbox repo must be set by `selftest.sandbox_repo`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"description": "Labelled conformance gate verdicts from real lanes. true-block: the gate was right to block; false-block: it blocked a correct change.",
|
|
4
|
+
"cases": [
|
|
5
|
+
{ "id": "b20261031-085207#4865-a", "batch": "b20261031-085207", "issue": 4865, "kind": "conformance.omission", "label": "true-block", "note": "builder silently deferred an issue requirement" },
|
|
6
|
+
{ "id": "b20261031-085207#4865-b", "batch": "b20261031-085207", "issue": 4865, "kind": "conformance.omission", "label": "true-block", "note": "same omission on the second round" },
|
|
7
|
+
{ "id": "b20261031-085207#4873-omission", "batch": "b20261031-085207", "issue": 4873, "kind": "conformance.omission", "label": "true-block", "note": "builder narrowed a suggested fix" },
|
|
8
|
+
{ "id": "b20261031-085207#4866", "batch": "b20261031-085207", "issue": 4866, "kind": "conformance.guard_broken", "label": "false-block", "note": "additive enum code plus its coverage entry" },
|
|
9
|
+
{ "id": "b20261031-085207#4873-guard", "batch": "b20261031-085207", "issue": 4873, "kind": "conformance.guard_broken", "label": "false-block", "note": "new gh api call the base test's fake gh could not answer" },
|
|
10
|
+
{ "id": "b20261031-085207#4899", "batch": "b20261031-085207", "issue": 4899, "kind": "conformance.guard_broken", "label": "false-block", "note": "intentional change to a guarded behaviour" },
|
|
11
|
+
{ "id": "b20261031-085207#4887", "batch": "b20261031-085207", "issue": 4887, "kind": "conformance.scope_creep", "label": "false-block", "note": "doc edit the repo's freshness rule requires" }
|
|
12
|
+
]
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgedock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.42.0",
|
|
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"
|
package/pool/image/Dockerfile
CHANGED
|
@@ -38,10 +38,11 @@ RUN curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | env UV_INSTALL_
|
|
|
38
38
|
&& uv tool install "poetry==${POETRY_VERSION}"
|
|
39
39
|
|
|
40
40
|
COPY entrypoint.sh /usr/local/bin/cs-entrypoint
|
|
41
|
+
COPY warm-refresh.sh /usr/local/bin/cs-warm-refresh
|
|
41
42
|
COPY cs-cred /usr/local/bin/cs-cred
|
|
42
43
|
COPY cs-relay /usr/local/bin/cs-relay
|
|
43
44
|
COPY gh-shim /opt/cs/bin/gh
|
|
44
45
|
COPY claude-shim /opt/cs/bin/claude
|
|
45
|
-
RUN chmod 0755 /usr/local/bin/cs-entrypoint /usr/local/bin/cs-cred /usr/local/bin/cs-relay /opt/cs/bin/gh /opt/cs/bin/claude
|
|
46
|
+
RUN chmod 0755 /usr/local/bin/cs-entrypoint /usr/local/bin/cs-warm-refresh /usr/local/bin/cs-cred /usr/local/bin/cs-relay /opt/cs/bin/gh /opt/cs/bin/claude
|
|
46
47
|
WORKDIR /work
|
|
47
48
|
ENTRYPOINT ["/usr/local/bin/cs-entrypoint"]
|
package/pool/image/entrypoint.sh
CHANGED
|
@@ -28,6 +28,46 @@ if [ -n "${CS_CRED_BASE:-}" ]; then
|
|
|
28
28
|
elif [ -n "${GH_TOKEN:-}" ]; then
|
|
29
29
|
gh auth setup-git >/dev/null 2>&1 || true
|
|
30
30
|
fi
|
|
31
|
+
checkout_repo() {
|
|
32
|
+
if [ -n "${CS_REPO_URL:-}" ]; then
|
|
33
|
+
# Shared bare mirror in the cache volume: later lanes fetch a delta instead of a full clone.
|
|
34
|
+
mirror="/cache/git/$(echo "${CS_REPO_SLUG:-repo}" | tr '/' '_').git"
|
|
35
|
+
if [ -d "$mirror" ]; then
|
|
36
|
+
git -C "$mirror" fetch --prune --quiet origin '+refs/heads/*:refs/heads/*' || true
|
|
37
|
+
else
|
|
38
|
+
mkdir -p /cache/git
|
|
39
|
+
git clone --mirror --quiet "$CS_REPO_URL" "$mirror"
|
|
40
|
+
fi
|
|
41
|
+
rm -rf /work/repo
|
|
42
|
+
git clone --quiet --reference "$mirror" ${CS_REPO_REF:+--branch "$CS_REPO_REF"} "$CS_REPO_URL" /work/repo
|
|
43
|
+
cd /work/repo
|
|
44
|
+
fi
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Warm sandbox (#4912): prepare the checkout, then wait for the agent's claim (lane env, command, ref in /claim/claim.json).
|
|
48
|
+
# The container env is fixed at start, so the lane arrives as a file that is read once and deleted.
|
|
49
|
+
if [ "${CS_WARM:-}" = "1" ]; then
|
|
50
|
+
checkout_repo
|
|
51
|
+
# Prime the lockfile-keyed dependency cache (best effort): the lane's own install is then a hit, not a download.
|
|
52
|
+
fd_bin="$(dirname "$(readlink -f "$(command -v forgedock)")" 2>/dev/null || true)"
|
|
53
|
+
if [ -f "$fd_bin/lean/depcache.mjs" ] && [ -d /work/repo ]; then
|
|
54
|
+
node --input-type=module -e 'const { makeDepCache } = await import(process.argv[1]); const { execFile } = await import("node:child_process"); const { promisify } = await import("node:util"); const x = promisify(execFile);
|
|
55
|
+
await makeDepCache({ exec: async (c, a, o = {}) => String((await x(c, a, { cwd: o.cwd, env: { ...process.env, ...(o.env ?? {}) }, timeout: o.timeoutMs ?? 0, maxBuffer: 1 << 28 })).stdout) }).prepare({ wt: "/work/repo", repoKey: process.argv[2] });' \
|
|
56
|
+
"$fd_bin/lean/depcache.mjs" "${CS_REPO_SLUG:-repo}" || echo "[computeshare] warm dependency prime skipped"
|
|
57
|
+
fi
|
|
58
|
+
touch /csout/warm-ready
|
|
59
|
+
while [ ! -f /claim/claim.json ]; do sleep 0.5; done
|
|
60
|
+
eval "$(node -e '
|
|
61
|
+
const c = JSON.parse(require("fs").readFileSync("/claim/claim.json", "utf8")), q = (v) => "\x27" + String(v).replace(/\x27/g, "\x27\\\x27\x27") + "\x27";
|
|
62
|
+
const out = Object.entries(c.env ?? {}).filter(([k]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k)).map(([k, v]) => "export " + k + "=" + q(v));
|
|
63
|
+
out.push("CS_CLAIM_REF=" + q(c.ref ?? ""), "set -- " + (c.command ?? []).map(q).join(" "));
|
|
64
|
+
console.log(out.join("\n"));
|
|
65
|
+
')"
|
|
66
|
+
rm -f /claim/claim.json
|
|
67
|
+
unset CS_WARM
|
|
68
|
+
warm_claimed=1
|
|
69
|
+
fi
|
|
70
|
+
|
|
31
71
|
# #4778: allowlisted read-only host wrappers (e.g. obs-psql) run through cs-relay on the dispatching host, unchanged argv.
|
|
32
72
|
if [ -n "${CS_RELAY_COMMANDS:-}" ]; then
|
|
33
73
|
mkdir -p /opt/cs/bin
|
|
@@ -41,17 +81,10 @@ fi
|
|
|
41
81
|
git config --global user.name "${GIT_AUTHOR_NAME:-forgedock}"
|
|
42
82
|
git config --global user.email "${GIT_AUTHOR_EMAIL:-forgedock@localhost}"
|
|
43
83
|
|
|
44
|
-
if [ -
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
git -C "$mirror" fetch --prune --quiet origin '+refs/heads/*:refs/heads/*' || true
|
|
49
|
-
else
|
|
50
|
-
mkdir -p /cache/git
|
|
51
|
-
git clone --mirror --quiet "$CS_REPO_URL" "$mirror"
|
|
52
|
-
fi
|
|
53
|
-
rm -rf /work/repo
|
|
54
|
-
git clone --quiet --reference "$mirror" ${CS_REPO_REF:+--branch "$CS_REPO_REF"} "$CS_REPO_URL" /work/repo
|
|
84
|
+
if [ -z "${warm_claimed:-}" ]; then
|
|
85
|
+
checkout_repo
|
|
86
|
+
else
|
|
87
|
+
(cd /work/repo && /usr/local/bin/cs-warm-refresh "${CS_CLAIM_REF:-}")
|
|
55
88
|
cd /work/repo
|
|
56
89
|
fi
|
|
57
90
|
|