forgedock 1.36.0 → 1.37.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/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +10 -1
- package/bin/engine/events.test.mjs +15 -1
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +268 -37
- package/bin/engine.mjs +3 -1
- package/bin/forgedock.mjs +108 -5
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +10 -4
- package/bin/lean/batch.test.mjs +75 -1
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +125 -11
- package/bin/lean/conformance.test.mjs +122 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +29 -14
- package/bin/lean/execution.mjs +60 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lean.mjs +134 -28
- package/bin/lean/lean.test.mjs +239 -22
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +83 -15
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +38 -6
- package/bin/lean/postdeploy-check.test.mjs +62 -2
- package/bin/lean/postdeploy-monitor.mjs +12 -2
- package/bin/lean/postdeploy-monitor.test.mjs +27 -1
- package/bin/lean/prefilter.mjs +83 -23
- package/bin/lean/prefilter.test.mjs +73 -1
- package/bin/lean/probe.mjs +2 -2
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +15 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
- package/bin/lean/sandbox-remote.mjs +4 -4
- package/bin/lean/schedd.mjs +53 -9
- package/bin/lean/schedd.test.mjs +77 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/index.mjs +21 -2
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +8 -1
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +71 -2
- package/docs/EVENTS.md +1 -4
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +20 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/file-deferred-followups.sh +285 -77
- package/scripts/file-deferred-followups.test.sh +111 -4
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { openTrain, createTrain, insertMilestone, resolveCaps, runTrain, stopOf, trainCli, STOP_REASONS } from "./train.mjs";
|
|
9
|
+
|
|
10
|
+
const live = { issues: [
|
|
11
|
+
{ number: 1, milestone: "m1", state: "OPEN", body: "" },
|
|
12
|
+
{ number: 2, milestone: "m2", state: "OPEN", body: "Depends on #1" },
|
|
13
|
+
{ number: 3, milestone: "m3", state: "OPEN", body: "" },
|
|
14
|
+
] };
|
|
15
|
+
const tmp = () => tmpDir("train-");
|
|
16
|
+
const okRun = (calls) => async (slug, o) => { calls.push([slug, o]); return { ok: true, batch: `b-${slug}`, rows: [] }; };
|
|
17
|
+
const life = async (titles) => titles.map((title) => ({ title, state: "shipped:staging" }));
|
|
18
|
+
|
|
19
|
+
test("createTrain keeps order, refuses a dependency on a later milestone", () => {
|
|
20
|
+
const s = createTrain({ slugs: ["m1", "m2"], live });
|
|
21
|
+
assert.deepEqual(s.order, ["m1", "m2"]);
|
|
22
|
+
assert.equal(s.milestones.m1.status, "pending");
|
|
23
|
+
assert.throws(() => createTrain({ slugs: ["m2", "m1"], live }), /train order invalid/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("insertMilestone re-validates and leaves the state unchanged on refusal", () => {
|
|
27
|
+
const s = createTrain({ slugs: ["m1", "m2"], live });
|
|
28
|
+
const n = insertMilestone(s, "m3", { after: "m1", live });
|
|
29
|
+
assert.deepEqual(n.order, ["m1", "m3", "m2"]);
|
|
30
|
+
assert.deepEqual(s.order, ["m1", "m2"]);
|
|
31
|
+
assert.throws(() => insertMilestone(s, "m1", { live }), /already in the train/);
|
|
32
|
+
const live2 = { issues: [...live.issues, { number: 4, milestone: "m4", state: "OPEN", body: "Depends on #2" }, { number: 5, milestone: "m2", state: "OPEN", body: "Depends on #4" }] };
|
|
33
|
+
assert.throws(() => insertMilestone(s, "m4", { live: live2 }), /train order invalid/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("runTrain runs in order, persists lifecycle, and resumes from the state file", async () => {
|
|
37
|
+
const root = tmp(), train = openTrain(root, "t1"), calls = [];
|
|
38
|
+
const r = await runTrain({ train, state: createTrain({ slugs: ["m1", "m2"], live }), runMilestone: okRun(calls), onBatchEnd: life, readLive: async () => live, caps: resolveCaps({}) });
|
|
39
|
+
assert.equal(r.ok, true);
|
|
40
|
+
assert.deepEqual(calls.map((c) => c[0]), ["m1", "m2"]);
|
|
41
|
+
const saved = train.load();
|
|
42
|
+
assert.equal(saved.milestones.m2.status, "done");
|
|
43
|
+
assert.equal(saved.milestones.m2.lifecycle, "shipped:staging");
|
|
44
|
+
assert.equal(saved.milestones.m1.batch, "b-m1");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a failed milestone ends in a typed STOP and --resume continues through its batch", async () => {
|
|
48
|
+
const root = tmp(), train = openTrain(root, "t2"), calls = [];
|
|
49
|
+
let fail = true;
|
|
50
|
+
const run = async (slug, o) => { calls.push([slug, o.resume]); return fail && slug === "m1" ? { ok: false, batch: "b1", rows: [{ issue: 1, outcome: "needs-human" }] } : { ok: true, rows: [] }; };
|
|
51
|
+
const args = { train, runMilestone: run, onBatchEnd: life, readLive: async () => live, caps: resolveCaps({}) };
|
|
52
|
+
const a = await runTrain({ ...args, state: createTrain({ slugs: ["m1", "m2"], live }) });
|
|
53
|
+
assert.equal(a.ok, false);
|
|
54
|
+
assert.equal(a.state.stop.reason, "needs-human");
|
|
55
|
+
assert.ok(STOP_REASONS.includes(a.state.stop.reason));
|
|
56
|
+
assert.equal(a.state.milestones.m2.status, "pending");
|
|
57
|
+
fail = false;
|
|
58
|
+
const b = await runTrain({ ...args, state: train.load() });
|
|
59
|
+
assert.equal(b.ok, true);
|
|
60
|
+
assert.deepEqual(calls.slice(1), [["m1", "b1"], ["m2", null]]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("open issues left after a batch stop the train with milestone-open", async () => {
|
|
64
|
+
const r = await runTrain({ train: openTrain(tmp(), "t3"), state: createTrain({ slugs: ["m1"] }), runMilestone: okRun([]), onBatchEnd: async (t) => [{ title: t[0], state: "building" }], caps: resolveCaps({}) });
|
|
65
|
+
assert.equal(r.state.stop.reason, "milestone-open");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("stopOf maps row outcomes to typed reasons", () => {
|
|
69
|
+
assert.equal(stopOf({ rows: [{ outcome: "dependency-open" }] }), "dependency-unmet");
|
|
70
|
+
assert.equal(stopOf({ rows: [{ outcome: "budget-exceeded" }] }), "budget");
|
|
71
|
+
assert.equal(stopOf({ rows: [] }), "error");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("parallel milestones run together and split one cap", async () => {
|
|
75
|
+
const caps = resolveCaps({ globalParallel: 6, parallelMilestones: 2 });
|
|
76
|
+
assert.equal(caps.perMilestone, 3);
|
|
77
|
+
let live_ = 0, max = 0;
|
|
78
|
+
const run = async () => { live_++; max = Math.max(max, live_); await new Promise((r) => setTimeout(r, 10)); live_--; return { ok: true, rows: [] }; };
|
|
79
|
+
const r = await runTrain({ train: openTrain(tmp(), "t4"), state: createTrain({ slugs: ["m1", "m3"], parallelMilestones: 2 }), runMilestone: run, onBatchEnd: life, caps });
|
|
80
|
+
assert.equal(r.ok, true);
|
|
81
|
+
assert.equal(max, 2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a conflicting --parallel override warns and is ignored; the cap source is global_parallel", () => {
|
|
85
|
+
const c = resolveCaps({ requested: 32, globalParallel: 12 });
|
|
86
|
+
assert.equal(c.cap, 12);
|
|
87
|
+
assert.match(c.warnings[0], /conflicts.*ignored/);
|
|
88
|
+
assert.equal(resolveCaps({ requested: 12, globalParallel: 12 }).warnings.length, 0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("trainCli: forge.yaml is byte-identical after a run and lanes get no branches.staging override", async () => {
|
|
92
|
+
const root = tmp(), cwd = tmp(), out = [], calls = [];
|
|
93
|
+
const yaml = "branches:\n staging: staging\norchestration:\n global_parallel: 4\n";
|
|
94
|
+
writeFileSync(join(cwd, "forge.yaml"), yaml);
|
|
95
|
+
const before = readFileSync(join(cwd, "forge.yaml"));
|
|
96
|
+
const r = await trainCli(["m1", "m2", "--parallel", "32"], { root, id: "t5", globalParallel: 4, readLive: async () => live, runMilestone: okRun(calls), onBatchEnd: life, print: (l) => out.push(l) });
|
|
97
|
+
assert.equal(r.ok, true);
|
|
98
|
+
assert.ok(out.some((l) => /conflicts with the lane cap 4/.test(l)));
|
|
99
|
+
assert.equal(calls[0][1].parallel, 4);
|
|
100
|
+
assert.deepEqual(readFileSync(join(cwd, "forge.yaml")), before);
|
|
101
|
+
assert.equal(openTrain(root, "t5").load().milestones.m2.status, "done");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("trainCli --insert adds a milestone explicitly; --resume clears nothing it did not own", async () => {
|
|
105
|
+
const root = tmp(), out = [];
|
|
106
|
+
const deps = { root, id: "t6", readLive: async () => live, runMilestone: okRun([]), onBatchEnd: life, print: (l) => out.push(l) };
|
|
107
|
+
await trainCli(["m1"], deps);
|
|
108
|
+
const r = await trainCli(["--insert", "t6", "m3"], deps);
|
|
109
|
+
assert.deepEqual(r.state.order, ["m1", "m3"]);
|
|
110
|
+
const done = await trainCli(["--resume", "t6"], deps);
|
|
111
|
+
assert.equal(done.state.milestones.m3.status, "done");
|
|
112
|
+
await assert.rejects(() => trainCli(["--resume", "nope"], deps), /no train/);
|
|
113
|
+
});
|
package/bin/pool/cli.mjs
CHANGED
|
@@ -42,12 +42,14 @@ export async function runPoolCli(argv, deps = {}) {
|
|
|
42
42
|
if (r.status !== 0) throw new Error(`install-router.sh exited ${r.status}`);
|
|
43
43
|
break;
|
|
44
44
|
}
|
|
45
|
+
process.title = "forgedock-pool-router"; // distinctive name for OOM-killer avoid lists and ps (#4673)
|
|
45
46
|
const cfg = routerConfig();
|
|
46
47
|
(deps.startRouter ?? startRouter)(cfg);
|
|
47
48
|
console.log(`computeshare router on ${cfg.listen.host}:${cfg.listen.port} (public ${cfg.public_url})`);
|
|
48
49
|
break;
|
|
49
50
|
}
|
|
50
51
|
case "agent": {
|
|
52
|
+
process.title = "forgedock-pool-agent";
|
|
51
53
|
const cfg = agentConfig();
|
|
52
54
|
startAgent(cfg, { docker: dockerCli });
|
|
53
55
|
console.log(`computeshare agent ${cfg.name} on ${cfg.listen.host}:${cfg.listen.port}: ${cfg.allow_cpus} cpus, ${cfg.allow_mem_gb} GB reserved for lanes`);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
const sh = (cmd, args) => { const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 5000 }); return r.status === 0 ? r.stdout : null; };
|
|
6
|
+
|
|
7
|
+
/** Last exit reason in journal lines, or null. Matches systemd's "Main process exited, code=killed, status=15/TERM" and oom-kill results. */
|
|
8
|
+
export function lastExitReason(lines) {
|
|
9
|
+
let reason = null;
|
|
10
|
+
for (const l of lines) {
|
|
11
|
+
const m = /Main process exited, (code=\S+, status=\S+)/.exec(l) || /(Failed with result '(?:oom-kill|signal|exit-code)')/.exec(l);
|
|
12
|
+
if (m) reason = m[1];
|
|
13
|
+
}
|
|
14
|
+
return reason;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Did a systemd --user unit restart in the last hour? `run(cmd,args)` returns stdout or null (injected for tests).
|
|
19
|
+
* "skip" when the unit is absent or systemd is unavailable. Restarts come from the journal because NRestarts is only cumulative.
|
|
20
|
+
*/
|
|
21
|
+
export function restartCheck({ unit, run = sh } = {}) {
|
|
22
|
+
const show = run("systemctl", ["--user", "show", unit, "-p", "NRestarts", "-p", "LoadState"]);
|
|
23
|
+
if (!show || /LoadState=not-found/.test(show)) return { status: "skip" };
|
|
24
|
+
const total = Number(/NRestarts=(\d+)/.exec(show)?.[1] ?? 0);
|
|
25
|
+
const lines = (run("journalctl", ["--user", "-u", unit, "--since", "1 hour ago", "--no-pager", "-o", "cat"]) ?? "").split("\n");
|
|
26
|
+
const recent = lines.filter((l) => /Scheduled restart job/.test(l)).length;
|
|
27
|
+
if (recent === 0) return { status: "pass", detail: `no restarts in the last hour (NRestarts=${total})` };
|
|
28
|
+
const why = lastExitReason(lines);
|
|
29
|
+
return { status: "warn", detail: `${unit} restarted ${recent}x in the last hour (NRestarts=${total})${why ? `; last exit: ${why}` : ""}. Check for OOM kills (earlyoom/systemd-oomd) under memory pressure` };
|
|
30
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
/**
|
|
5
|
+
* review-context.mjs — detect and refresh a stale forge.yaml `review.context` / `review.tech_stack` (#4666).
|
|
6
|
+
*
|
|
7
|
+
* The checks are deterministic (no LLM): manifests, the decision log and the context text are compared directly.
|
|
8
|
+
* Only `proposeRefresh` may use the enrichment backend, and only when the caller passes one in.
|
|
9
|
+
*
|
|
10
|
+
* node bin/review-context.mjs check --config <forge.yaml> [--root <dir>] [--repo owner/name] --json
|
|
11
|
+
*
|
|
12
|
+
* Exit 0 always for `check` (a failing check must never block a review); the JSON carries the result.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
15
|
+
import { dirname, join, resolve } from "path";
|
|
16
|
+
import { execFileSync } from "child_process";
|
|
17
|
+
import { fileURLToPath } from "url";
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_MAX_MERGED_PRS = 50;
|
|
20
|
+
export const DECISION_LOG_CANDIDATES = ["docs/decisions.md", "DECISIONS.md", "docs/decision-log.md", "devdocs/project/decisions.md"];
|
|
21
|
+
|
|
22
|
+
// label as written in tech_stack -> package name as written in a manifest
|
|
23
|
+
const FRAMEWORKS = [
|
|
24
|
+
["Next.js", "next"], ["React", "react"], ["Vue", "vue"], ["Nuxt", "nuxt"], ["Svelte", "svelte"], ["SvelteKit", "@sveltejs/kit"],
|
|
25
|
+
["Angular", "@angular/core"], ["Remix", "@remix-run/react"], ["Astro", "astro"], ["Express", "express"], ["Fastify", "fastify"],
|
|
26
|
+
["NestJS", "@nestjs/core"], ["Tailwind", "tailwindcss"], ["Prisma", "prisma"], ["Drizzle", "drizzle-orm"],
|
|
27
|
+
["FastAPI", "fastapi"], ["Django", "django"], ["Flask", "flask"], ["SQLAlchemy", "sqlalchemy"],
|
|
28
|
+
["Rails", "rails"], ["Laravel", "laravel/framework"], ["Gin", "gin-gonic/gin"], ["Axum", "axum"], ["Actix", "actix-web"],
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", ".venv", "venv", "vendor", "target", "__pycache__", ".claude", ".forge-lean"]);
|
|
32
|
+
const MANIFEST_RE = /^(package\.json|pyproject\.toml|requirements[\w.-]*\.txt|go\.mod|Cargo\.toml|Gemfile|composer\.json)$/;
|
|
33
|
+
|
|
34
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
35
|
+
const wordRe = (w, flags = "i") => new RegExp(`(^|[^A-Za-z0-9_.-])${esc(w)}($|[^A-Za-z0-9_.-])`, flags);
|
|
36
|
+
|
|
37
|
+
// ── forge.yaml review block (no YAML parser in this repo: a tolerant line scanner) ──────────────────────────────
|
|
38
|
+
|
|
39
|
+
function reviewRange(lines) {
|
|
40
|
+
const start = lines.findIndex((l) => /^review:\s*(#.*)?$/.test(l));
|
|
41
|
+
if (start === -1) return null;
|
|
42
|
+
let end = lines.length;
|
|
43
|
+
for (let i = start + 1; i < lines.length; i++) if (/^\S/.test(lines[i]) && !lines[i].startsWith("#")) { end = i; break; }
|
|
44
|
+
return { start, end };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scalar(raw) {
|
|
48
|
+
const v = raw.trim();
|
|
49
|
+
if (v.startsWith('"')) { try { return JSON.parse(v.replace(/\s+#.*$/, "")); } catch { return v.slice(1, -1); } }
|
|
50
|
+
if (v.startsWith("'")) return v.slice(1, v.lastIndexOf("'")).replace(/''/g, "'");
|
|
51
|
+
return v.replace(/\s+#.*$/, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reads the scalar keys of the top-level `review:` block (block scalars `|`/`>` included). Missing -> "". */
|
|
55
|
+
export function readReviewFields(yamlText) {
|
|
56
|
+
const lines = yamlText.split(/\r?\n/);
|
|
57
|
+
const r = reviewRange(lines);
|
|
58
|
+
const out = {};
|
|
59
|
+
if (!r) return out;
|
|
60
|
+
for (let i = r.start + 1; i < r.end; i++) {
|
|
61
|
+
const m = /^ ([A-Za-z_]+):\s*(.*)$/.exec(lines[i]);
|
|
62
|
+
if (!m) continue;
|
|
63
|
+
const [, key, rest] = m;
|
|
64
|
+
if (/^[|>][+-]?\s*(#.*)?$/.test(rest)) {
|
|
65
|
+
const body = [];
|
|
66
|
+
let j = i + 1;
|
|
67
|
+
while (j < r.end && (/^\s*$/.test(lines[j]) || /^ {3,}/.test(lines[j]))) body.push(lines[j++].replace(/^ {4}/, "").replace(/^ +$/, ""));
|
|
68
|
+
out[key] = body.join("\n").replace(/\n+$/, "");
|
|
69
|
+
i = j - 1;
|
|
70
|
+
} else if (rest.trim() !== "") out[key] = scalar(rest);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Returns yamlText with the given review keys replaced (or added). Every other line is left untouched. */
|
|
76
|
+
export function writeReviewFields(yamlText, fields) {
|
|
77
|
+
const lines = yamlText.split("\n");
|
|
78
|
+
let r = reviewRange(lines);
|
|
79
|
+
if (!r) {
|
|
80
|
+
if (lines[lines.length - 1] !== "") lines.push("");
|
|
81
|
+
lines.push("review:");
|
|
82
|
+
r = { start: lines.length - 1, end: lines.length };
|
|
83
|
+
}
|
|
84
|
+
const keys = Object.keys(fields);
|
|
85
|
+
const kept = [];
|
|
86
|
+
for (let i = r.start + 1; i < r.end; i++) {
|
|
87
|
+
const m = /^ ([A-Za-z_]+):/.exec(lines[i]);
|
|
88
|
+
if (m && keys.includes(m[1])) {
|
|
89
|
+
while (i + 1 < r.end && (/^ {3,}/.test(lines[i + 1]) || (/^\s*$/.test(lines[i + 1]) && /^ {3,}/.test(lines[i + 2] || "")))) i++;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
kept.push(lines[i]);
|
|
93
|
+
}
|
|
94
|
+
const added = [];
|
|
95
|
+
for (const k of keys) {
|
|
96
|
+
const v = String(fields[k]);
|
|
97
|
+
if (v.includes("\n")) added.push(` ${k}: |`, ...v.replace(/\n+$/, "").split("\n").map((l) => (l ? ` ${l}` : "")));
|
|
98
|
+
else added.push(` ${k}: ${JSON.stringify(v)}`);
|
|
99
|
+
}
|
|
100
|
+
return [...lines.slice(0, r.start + 1), ...added, ...kept, ...lines.slice(r.end)].join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── facts: manifests and the decision log ────────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/** Dependency names and raw manifest text under `root` (depth <= 3, skipping vendored dirs). */
|
|
106
|
+
export function collectManifestFacts(root) {
|
|
107
|
+
const files = [];
|
|
108
|
+
const walk = (dir, depth) => {
|
|
109
|
+
let entries;
|
|
110
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
111
|
+
for (const name of entries) {
|
|
112
|
+
if (files.length >= 200) return;
|
|
113
|
+
const p = join(dir, name);
|
|
114
|
+
let st;
|
|
115
|
+
try { st = statSync(p); } catch { continue; }
|
|
116
|
+
if (st.isDirectory()) { if (depth < 3 && !SKIP_DIRS.has(name)) walk(p, depth + 1); }
|
|
117
|
+
else if (MANIFEST_RE.test(name) && st.size < 2_000_000) files.push(p);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
walk(root, 0);
|
|
121
|
+
const parts = [];
|
|
122
|
+
let hasSourceManifest = false;
|
|
123
|
+
for (const f of files) {
|
|
124
|
+
let text;
|
|
125
|
+
try { text = readFileSync(f, "utf8"); } catch { continue; }
|
|
126
|
+
if (f.endsWith("package.json")) {
|
|
127
|
+
try {
|
|
128
|
+
const j = JSON.parse(text);
|
|
129
|
+
const deps = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].flatMap((k) => Object.keys(j[k] || {}));
|
|
130
|
+
if (deps.length) hasSourceManifest = true;
|
|
131
|
+
parts.push(deps.join("\n"));
|
|
132
|
+
} catch { /* unparsable manifest: ignore */ }
|
|
133
|
+
} else {
|
|
134
|
+
hasSourceManifest = true;
|
|
135
|
+
parts.push(text);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { files, hasSourceManifest, haystack: parts.join("\n").toLowerCase() };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function findDecisionLog(root, configured) {
|
|
142
|
+
for (const rel of [configured, ...DECISION_LOG_CANDIDATES].filter(Boolean)) {
|
|
143
|
+
const p = resolve(root, rel);
|
|
144
|
+
if (existsSync(p)) { try { return readFileSync(p, "utf8"); } catch { /* next */ } }
|
|
145
|
+
}
|
|
146
|
+
return "";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const ID_RE = /\b(?:[A-Z]{1,5}-?\d{1,4})\b/g;
|
|
150
|
+
|
|
151
|
+
/** IDs the decision log marks superseded: "D-12 ... superseded", "Status: superseded" under a "## D-12" heading, "D-15 supersedes D-12". */
|
|
152
|
+
export function supersededDecisionIds(logText) {
|
|
153
|
+
const out = new Set();
|
|
154
|
+
let lastId = [];
|
|
155
|
+
for (const line of logText.split(/\r?\n/)) {
|
|
156
|
+
const m = /supersed\w*/i.exec(line);
|
|
157
|
+
if (!m) {
|
|
158
|
+
const ids = line.match(ID_RE);
|
|
159
|
+
if (ids) lastId = ids;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (/^supersedes$/i.test(m[0])) (line.slice(m.index).match(ID_RE) || []).forEach((i) => out.add(i));
|
|
163
|
+
else {
|
|
164
|
+
const before = line.slice(0, m.index).match(ID_RE) || [];
|
|
165
|
+
(before.length ? before : lastId).forEach((i) => out.add(i));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── detection ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/** Reasons the context is stale: [{code, detail}]. Empty = fresh (or nothing to compare against). */
|
|
174
|
+
export function detectStale({ techStack = "", context = "", manifests, decisionLogText = "" }) {
|
|
175
|
+
const reasons = [];
|
|
176
|
+
if (manifests?.hasSourceManifest) {
|
|
177
|
+
const absent = FRAMEWORKS.filter(([label, pkg]) => wordRe(label).test(techStack) && !wordRe(pkg).test(manifests.haystack)).map(([l]) => l);
|
|
178
|
+
if (absent.length) reasons.push({ code: "tech_stack_absent", detail: `tech_stack names ${absent.join(", ")} but no manifest lists it` });
|
|
179
|
+
if (/docs[- ]only|\bno (?:product |source |application )?code\b/i.test(context)) {
|
|
180
|
+
reasons.push({ code: "docs_only_phrasing", detail: "context says the repo is docs-only / has no code, but source manifests exist" });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (decisionLogText && context) {
|
|
184
|
+
const cited = [...supersededDecisionIds(decisionLogText)].filter((id) => wordRe(id, "").test(context));
|
|
185
|
+
if (cited.length) reasons.push({ code: "superseded_decision", detail: `context cites superseded decision(s) ${cited.join(", ")}` });
|
|
186
|
+
}
|
|
187
|
+
return reasons;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Advisory once more than `maxMergedPrs` PRs merged since the context was generated. Unknown count = not advisory. */
|
|
191
|
+
export function contextAge({ generatedAt = "", mergedSince = null, maxMergedPrs = DEFAULT_MAX_MERGED_PRS }) {
|
|
192
|
+
const max = Number.isFinite(maxMergedPrs) && maxMergedPrs > 0 ? maxMergedPrs : DEFAULT_MAX_MERGED_PRS;
|
|
193
|
+
return { generatedAt, mergedSince, maxMergedPrs: max, advisory: Boolean(generatedAt) && mergedSince != null && mergedSince > max };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Merged PR count since `generatedAt` via gh; null when it cannot be determined. */
|
|
197
|
+
export function countMergedSince(repo, generatedAt, run = execFileSync) {
|
|
198
|
+
if (!repo || !/^\d{4}-\d{2}-\d{2}/.test(generatedAt)) return null;
|
|
199
|
+
try {
|
|
200
|
+
const out = run("gh", ["pr", "list", "-R", repo, "--state", "merged", "--search", `merged:>=${generatedAt.slice(0, 10)}`, "--json", "number", "--limit", "200"], { encoding: "utf8", timeout: 20000, stdio: ["ignore", "pipe", "ignore"] });
|
|
201
|
+
const n = JSON.parse(out);
|
|
202
|
+
return Array.isArray(n) ? n.length : null;
|
|
203
|
+
} catch { return null; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Full check of one forge.yaml. `mergedCount` is injectable for tests. */
|
|
207
|
+
export function checkContext({ configPath, root, repo, mergedCount = countMergedSince }) {
|
|
208
|
+
let text = "";
|
|
209
|
+
try { text = readFileSync(configPath, "utf8"); } catch { return { stale: false, reasons: [], generated_at: "", merged_since: null, advisory: false, max_merged_prs: DEFAULT_MAX_MERGED_PRS }; }
|
|
210
|
+
const f = readReviewFields(text);
|
|
211
|
+
const manifests = collectManifestFacts(root);
|
|
212
|
+
const reasons = detectStale({ techStack: f.tech_stack || "", context: f.context || "", manifests, decisionLogText: findDecisionLog(root, f.decision_log) });
|
|
213
|
+
const generatedAt = f.context_generated_at || "";
|
|
214
|
+
const age = contextAge({ generatedAt, mergedSince: generatedAt ? mergedCount(repo, generatedAt) : null, maxMergedPrs: parseInt(f.context_max_merged_prs, 10) });
|
|
215
|
+
return { stale: reasons.length > 0, reasons, generated_at: generatedAt, merged_since: age.mergedSince, advisory: age.advisory, max_merged_prs: age.maxMergedPrs };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── refresh ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/** Tech stack from the frameworks the manifests actually list. */
|
|
221
|
+
export function techStackFromManifests(manifests) {
|
|
222
|
+
return FRAMEWORKS.filter(([, pkg]) => wordRe(pkg).test(manifests.haystack)).map(([l]) => l).join(", ");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Drops the sentences that made the context stale (docs-only phrasing, superseded decision IDs). */
|
|
226
|
+
export function pruneContext(context, { hasSourceManifest, superseded }) {
|
|
227
|
+
const stale = (s) => (hasSourceManifest && /docs[- ]only|\bno (?:product |source |application )?code\b/i.test(s)) || [...superseded].some((id) => wordRe(id, "").test(s));
|
|
228
|
+
return context.split("\n").map((line) => line.split(/(?<=[.!?])\s+/).filter((s) => !stale(s)).join(" ")).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Proposes new review fields from the current manifests and decision log, deterministically: tech_stack is rebuilt from
|
|
233
|
+
* the manifests, stale sentences are pruned from the context. `enrichFn({tech_stack, context, decisionLog, manifestFiles})`
|
|
234
|
+
* (optional, async) may rewrite them further.
|
|
235
|
+
*/
|
|
236
|
+
export async function proposeRefresh({ yamlText, root, now = new Date(), enrichFn, mergedTotal }) {
|
|
237
|
+
const cur = readReviewFields(yamlText);
|
|
238
|
+
const manifests = collectManifestFacts(root);
|
|
239
|
+
const log = findDecisionLog(root, cur.decision_log);
|
|
240
|
+
const names = manifests.files.map((p) => p.slice(root.length + 1));
|
|
241
|
+
const stack = techStackFromManifests(manifests) || cur.tech_stack || "";
|
|
242
|
+
const fields = {
|
|
243
|
+
tech_stack: stack,
|
|
244
|
+
context: pruneContext(cur.context || "", { hasSourceManifest: manifests.hasSourceManifest, superseded: supersededDecisionIds(log) }),
|
|
245
|
+
};
|
|
246
|
+
if (!fields.context && names.length) fields.context = `Source manifests: ${names.join(", ")}.`;
|
|
247
|
+
if (enrichFn) {
|
|
248
|
+
const e = await enrichFn({ ...fields, decisionLog: log, manifestFiles: names });
|
|
249
|
+
if (e && typeof e === "object") { if (e.tech_stack) fields.tech_stack = e.tech_stack; if (e.context) fields.context = e.context; }
|
|
250
|
+
}
|
|
251
|
+
fields.context_generated_at = now.toISOString().slice(0, 10);
|
|
252
|
+
if (mergedTotal != null) fields.context_generated_pr_count = String(mergedTotal);
|
|
253
|
+
const next = writeReviewFields(yamlText, fields);
|
|
254
|
+
return { next, diff: simpleDiff(yamlText, next), changed: next !== yamlText };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* `forgedock init --refresh-context`: show the diff, write only on approval. `deps` = {confirm, backup, write, interactive}.
|
|
259
|
+
* Returns the exit code (0 written or nothing to do, 1 declined / non-interactive / no forge.yaml).
|
|
260
|
+
*/
|
|
261
|
+
export async function refreshContextFlow({ cwd, out, deps, enrichFn, mergedTotal }) {
|
|
262
|
+
const path = join(cwd, "forge.yaml");
|
|
263
|
+
if (!existsSync(path)) { out.write("\n No forge.yaml here — run: npx forgedock init\n"); return 1; }
|
|
264
|
+
const { next, diff, changed } = await proposeRefresh({ yamlText: readFileSync(path, "utf8"), root: cwd, enrichFn, mergedTotal });
|
|
265
|
+
out.write(`\n review.context / review.tech_stack refresh (from manifests + decision log):\n\n${diff.split("\n").map((l) => ` ${l}`).join("\n")}\n\n`);
|
|
266
|
+
if (!changed) { out.write(" Nothing to change.\n"); return 0; }
|
|
267
|
+
if (!deps.interactive) { out.write(" Non-interactive run — forge.yaml left untouched. Re-run in a terminal to approve.\n"); return 1; }
|
|
268
|
+
if (!(await deps.confirm("Apply this change to forge.yaml? A backup will be created.", false))) { out.write(" Refresh cancelled — forge.yaml left untouched.\n"); return 1; }
|
|
269
|
+
const backup = deps.backup(path);
|
|
270
|
+
if (backup) out.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
|
|
271
|
+
deps.write(path, next);
|
|
272
|
+
out.write(" forge.yaml updated.\n");
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Minimal line diff (removed `-`, added `+`) over the lines that differ; enough for an approval prompt. */
|
|
277
|
+
export function simpleDiff(a, b) {
|
|
278
|
+
const x = a.split("\n"), y = b.split("\n");
|
|
279
|
+
let s = 0;
|
|
280
|
+
while (s < x.length && s < y.length && x[s] === y[s]) s++;
|
|
281
|
+
let e = 0;
|
|
282
|
+
while (e < x.length - s && e < y.length - s && x[x.length - 1 - e] === y[y.length - 1 - e]) e++;
|
|
283
|
+
return [...x.slice(s, x.length - e).map((l) => `- ${l}`), ...y.slice(s, y.length - e).map((l) => `+ ${l}`)].join("\n");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
289
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
290
|
+
const opt = (n) => { const i = rest.indexOf(n); return i === -1 ? "" : rest[i + 1] || ""; };
|
|
291
|
+
if (cmd !== "check" || !opt("--config")) {
|
|
292
|
+
process.stderr.write("usage: review-context.mjs check --config <forge.yaml> [--root <dir>] [--repo owner/name] --json\n");
|
|
293
|
+
process.exit(2);
|
|
294
|
+
}
|
|
295
|
+
const configPath = resolve(opt("--config"));
|
|
296
|
+
const res = checkContext({ configPath, root: resolve(opt("--root") || dirname(configPath)), repo: opt("--repo") });
|
|
297
|
+
process.stdout.write(JSON.stringify(res) + "\n");
|
|
298
|
+
}
|
package/bin/runner.mjs
CHANGED
|
@@ -2001,6 +2001,17 @@ export function listDescendants(pid, { psFn = null, maxCmd = 200 } = {}) {
|
|
|
2001
2001
|
/** #4410: cap of the tool uses one stream session records. */
|
|
2002
2002
|
const TOOL_USES_MAX = 5000;
|
|
2003
2003
|
|
|
2004
|
+
/**
|
|
2005
|
+
* #4690: limit events of the systemd scope the process `pid` runs in: `tasks` (pids.events `max`: forks refused at TasksMax)
|
|
2006
|
+
* and `memory` (memory.events `oom_kill`). null when its cgroup or the counters are unreadable (no cgroup v2, no scope).
|
|
2007
|
+
*/
|
|
2008
|
+
export function readScopeLimitEvents(pid, { procRoot = "/proc", cgroupRoot = "/sys/fs/cgroup" } = {}) {
|
|
2009
|
+
const line = readFileSync(join(procRoot, String(pid), "cgroup"), "utf8").split("\n").find((l) => l.startsWith("0::"));
|
|
2010
|
+
const path = line?.slice(3).trim();
|
|
2011
|
+
if (!path || !path.endsWith(".scope")) return null;
|
|
2012
|
+
const count = (file, key) => { try { return Number((readFileSync(join(cgroupRoot, path, file), "utf8").match(new RegExp(`^${key} (\\d+)`, "m")) ?? [])[1]) || 0; } catch { return 0; } };
|
|
2013
|
+
return { tasks: count("pids.events", "max"), memory: count("memory.events", "oom_kill") };
|
|
2014
|
+
}
|
|
2004
2015
|
/**
|
|
2005
2016
|
* Async twin of runCliBackend (core work-on live comments): the CLI runs under child_process.spawn so the caller's event
|
|
2006
2017
|
* loop keeps turning while the session runs. It returns the SAME envelope (and throws the same errors) because the
|
|
@@ -2024,7 +2035,7 @@ const TOOL_USES_MAX = 5000;
|
|
|
2024
2035
|
* (deduped by tool_use id, capped at TOOL_USES_MAX), for bin/lean/toolstats.mjs; absent on the non-stream path.
|
|
2025
2036
|
*/
|
|
2026
2037
|
export async function runCliBackendAsync(opts) {
|
|
2027
|
-
const { spawnFn: _ignored, onSpawn = null, idleMs = 0, maxMs = 0, stream = false, wrap = null, control = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null, ...rest } = opts;
|
|
2038
|
+
const { spawnFn: _ignored, onSpawn = null, idleMs = 0, maxMs = 0, stream = false, wrap = null, control = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null, limitProbe = readScopeLimitEvents, ...rest } = opts;
|
|
2028
2039
|
let captured = null;
|
|
2029
2040
|
try {
|
|
2030
2041
|
runCliBackend({ ...rest, spawnFn: (bin, args, o) => {
|
|
@@ -2047,7 +2058,9 @@ export async function runCliBackendAsync(opts) {
|
|
|
2047
2058
|
const j = args.indexOf("--output-format");
|
|
2048
2059
|
if (j >= 0) { args[j + 1] = "stream-json"; if (!args.includes("--verbose")) args.splice(j + 2, 0, "--verbose"); }
|
|
2049
2060
|
}
|
|
2050
|
-
|
|
2061
|
+
let scopeUnit = null; // #4690: set when `wrap` put the child in a limited systemd scope
|
|
2062
|
+
if (typeof wrap === "function") { const w = wrap(bin, args); ({ bin, args } = w); scopeUnit = w.unit ?? null; }
|
|
2063
|
+
let limitInfo = null;
|
|
2051
2064
|
const ceiling = maxMs > 0 ? maxMs : o.timeout;
|
|
2052
2065
|
let collected, idleInfo = null, ctxInfo = null, turnInfo = null;
|
|
2053
2066
|
const msgUsage = new Map(); // #4390: assistant message id -> its usage (a message spans several stream lines)
|
|
@@ -2099,7 +2112,12 @@ export async function runCliBackendAsync(opts) {
|
|
|
2099
2112
|
}
|
|
2100
2113
|
}
|
|
2101
2114
|
// #4405: a separate check, so the absolute ceiling still applies while the idle kill is extended (busy) or settling.
|
|
2102
|
-
|
|
2115
|
+
// #4690: the scope's pids/memory events say the lane hit its own limit (the scope cgroup is gone once the child exits, so sample live)
|
|
2116
|
+
if (scopeUnit && !limitInfo && !timedOut && !idleInfo && !ctxInfo && !turnInfo) {
|
|
2117
|
+
let ev = null; try { ev = limitProbe(child.pid); } catch { ev = null; }
|
|
2118
|
+
if (ev && (ev.tasks > 0 || ev.memory > 0)) { limitInfo = { limit: ev.tasks > 0 ? "tasks" : "memory", unit: scopeUnit, events: ev }; kill(); }
|
|
2119
|
+
}
|
|
2120
|
+
if (!timedOut && !idleInfo && !ctxInfo && !turnInfo && !limitInfo && now - startedAt - pausedTotal - (paused ? now - pausedAt : 0) >= ceiling) { timedOut = true; kill(); }
|
|
2103
2121
|
}, tick);
|
|
2104
2122
|
timer.unref?.();
|
|
2105
2123
|
const noteLine = (line) => {
|
|
@@ -2143,6 +2161,11 @@ export async function runCliBackendAsync(opts) {
|
|
|
2143
2161
|
} finally { if (sysDir) rmSync(sysDir, { recursive: true, force: true }); }
|
|
2144
2162
|
const sum = (k) => [...msgUsage.values()].reduce((a, u) => a + toFiniteUsageNumber(u[k]), 0);
|
|
2145
2163
|
const streamUsage = () => (msgUsage.size ? { input_tokens: sum("input_tokens"), output_tokens: sum("output_tokens"), cache_creation_input_tokens: sum("cache_creation_input_tokens"), cache_read_input_tokens: sum("cache_read_input_tokens") } : null);
|
|
2164
|
+
if (limitInfo) {
|
|
2165
|
+
const err = new Error(`claude CLI lane hit its scope ${limitInfo.limit} limit (${limitInfo.unit}) and was stopped`);
|
|
2166
|
+
err.code = "CLI_LANE_LIMIT"; err.limit = limitInfo.limit; err.unit = limitInfo.unit; err.events = limitInfo.events; err.toolUses = [...toolUses.values()];
|
|
2167
|
+
throw err;
|
|
2168
|
+
}
|
|
2146
2169
|
if (turnInfo) {
|
|
2147
2170
|
const err = new Error(`claude CLI session reached its turn budget (${turnMax} turns) and was stopped`);
|
|
2148
2171
|
err.code = "CLI_TURNS"; err.turns = msgUsage.size; err.turnMax = turnMax; err.lastTool = turnInfo.lastTool; err.usage = streamUsage(); err.toolUses = [...toolUses.values()];
|