forgedock 1.38.0 → 1.39.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.
@@ -14,12 +14,16 @@ export const MAX_LEASE_TTL_MS = 3 * 600_000;
14
14
  const PROVENANCE_QUERY = "query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){"
15
15
  + "issue(number:$number){body authorAssociation author{login __typename} editor{login __typename}}}}";
16
16
 
17
- export function makeProjector(io) {
17
+ export function makeProjector(io, { repo } = {}) {
18
18
  const gh = io.gh;
19
19
 
20
+ // Throws on an unreadable body: an empty string would make writeState wipe the issue (#4860).
20
21
  async function getBody(issue) {
21
- const out = await gh(["issue", "view", String(issue), "--json", "body"]);
22
- try { return JSON.parse(out).body ?? ""; } catch { return ""; }
22
+ const out = await gh(["issue", "view", String(issue), "--json", "body", ...(repo ? ["-R", repo] : [])]);
23
+ let body;
24
+ try { body = JSON.parse(out).body; } catch { throw new Error(`issue #${issue}: body read returned non-JSON output`); }
25
+ if (typeof body !== "string") throw new Error(`issue #${issue}: body read returned no body`);
26
+ return body;
23
27
  }
24
28
 
25
29
  /**
@@ -64,16 +68,21 @@ export function makeProjector(io) {
64
68
  },
65
69
 
66
70
  async writeState(issue, index) {
67
- const body = upsertStateBlock(await getBody(issue), index);
68
- await gh(["issue", "edit", String(issue), "--body", body]);
71
+ const old = await getBody(issue);
72
+ const body = upsertStateBlock(old, index);
73
+ // Each run of issue text around a FORGE:STATE block must survive on its own: the block is replaced in place, so text
74
+ // before and after it stays apart and a joined string would never match (#4871).
75
+ const dropped = old.split(/<!-- FORGE:STATE\s*[\s\S]*?-->/).map((t) => t.trim()).filter(Boolean).some((t) => !body.includes(t));
76
+ if (dropped) throw new Error(`issue #${issue}: refusing a state write that drops existing issue text`);
77
+ await gh(["issue", "edit", String(issue), "--body", body, ...(repo ? ["-R", repo] : [])]);
69
78
  },
70
79
 
71
80
  async setLabel(issue, label) {
72
- await gh(["issue", "edit", String(issue), "--add-label", label]);
81
+ await gh(["issue", "edit", String(issue), "--add-label", label, ...(repo ? ["-R", repo] : [])]);
73
82
  },
74
83
 
75
84
  async removeLabel(issue, label) {
76
- await gh(["issue", "edit", String(issue), "--remove-label", label]);
85
+ await gh(["issue", "edit", String(issue), "--remove-label", label, ...(repo ? ["-R", repo] : [])]);
77
86
  },
78
87
  };
79
88
  }
@@ -9,6 +9,7 @@
9
9
  * builder_tier/class/model/escalated 0|1, forge#4394)
10
10
  * SANDBOX_USAGE -> `sandbox_usage` (provider, sandbox_id, minutes, cost_usd, batch; one per settled remote lane, forge#4255)
11
11
  * DECISION_ENGINE -> `decision_engine` (decided, escalated, vetoed, reversed, mode, needs_human_before/after; forge#4766)
12
+ * PRODWATCH_METRICS -> `prodwatch_metrics` (source, sample, invalid, fp_rate, fix_ineffective_rate, recurrence_rate, signal_to_issue/merge/verified_ms; forge#4837)
12
13
  * STRUCTURE_DELTA -> `structure_delta` (edges_added/removed, import_files_added/removed, boundary_changed/loosened 0|1; forge#4159)
13
14
  * lean's merge-time record (forge#4165) adds milestone, the pr and string lists: deps_added and
14
15
  * new_edges / removed_edges as "from:to" comma-joined, top_growth "file+N", decisions "K1:lint",
@@ -84,6 +85,11 @@ export function mapRecord(rec, wallMs) {
84
85
  return { event: "decision_engine", phase: null, payload: compact({ decided: rec.decided, escalated: rec.escalated, vetoed: rec.vetoed, reversed: rec.reversed, mode: rec.mode,
85
86
  needs_human_before: rec.needs_human_before, needs_human_after: rec.needs_human_after }) };
86
87
  }
88
+ if (rec.event === "PRODWATCH_METRICS") { // #4837
89
+ return { event: "prodwatch_metrics", phase: null, payload: compact({ source: rec.source, sample: rec.sample, invalid: rec.invalid, fix_ineffective: rec.fix_ineffective, recurred: rec.recurred,
90
+ fp_rate: rec.fp_rate, fix_ineffective_rate: rec.fix_ineffective_rate, recurrence_rate: rec.recurrence_rate,
91
+ signal_to_issue_ms: rec.signal_to_issue_ms, signal_to_merge_ms: rec.signal_to_merge_ms, signal_to_verified_ms: rec.signal_to_verified_ms }) };
92
+ }
87
93
  if (rec.event === "CI_PENDING_WAIT") {
88
94
  return { event: "ci_pending_wait", phase: rec.phase ?? "review", payload: compact({ pr: rec.pr, minutes_waited: rec.minutes_waited, result: rec.result, polls: rec.polls, rate_limit_sleeps: rec.rate_limit_sleeps }) };
89
95
  }
@@ -27,6 +27,12 @@ describe("engine telemetry seam", () => {
27
27
  assert.equal("baseline_tokens" in q, false); assert.equal("lane_profile" in q, false);
28
28
  });
29
29
 
30
+ it("forge#4837: PRODWATCH_METRICS maps to prodwatch_metrics, absent values omitted", () => {
31
+ const m = mapRecord({ event: "PRODWATCH_METRICS", source: "memo", sample: 7, fp_rate: 0.25, signal_to_issue_ms: 1000, signal_to_merge_ms: null }, 0);
32
+ assert.equal(m.event, "prodwatch_metrics");
33
+ assert.deepEqual(m.payload, { source: "memo", sample: 7, fp_rate: 0.25, signal_to_issue_ms: 1000 });
34
+ });
35
+
30
36
  it("forge#4257: phase_attempt carries the backend, omitted when unreported", () => {
31
37
  assert.equal(mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1, backend: "api" }, 0).payload.backend, "api");
32
38
  // #4407: the per-attempt cost and the session running total ride along; absent fields are omitted
@@ -23,6 +23,7 @@ import { makeEventEmitter, probeSweepEvents, experimentEvent } from "./engine/ev
23
23
  import { readExperiment } from "./experiment.mjs";
24
24
  import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
25
25
  import { parseVerify } from "./lean/verify.mjs";
26
+ import { viewIssue } from "./lean/gh-assoc.mjs";
26
27
  import { parseExecution, detectNeeds, placementText } from "./lean/execution.mjs";
27
28
  import { relayPrefetchCommands } from "./lean/relay.mjs";
28
29
  import { parseTurnBudgets, formatTurnSummary, TURN_BUDGET_DEFAULTS } from "./lean/budget.mjs";
@@ -288,6 +289,13 @@ export async function readPostDeployMinAge(cwd = process.cwd()) {
288
289
  export function readMilestoneCloseOn(cwd = process.cwd()) {
289
290
  try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.milestones?.close_on ?? "").trim().toLowerCase() === "staging" ? "staging" : "default"; } catch { return "default"; }
290
291
  }
292
+ /** forge.yaml `agents.subagent_model` (#4843): the default model of subagents in sessions ForgeDock launches; null when unset or invalid. */
293
+ export function readSubagentModel(cwd = process.cwd()) {
294
+ try {
295
+ const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.agents?.subagent_model ?? "").trim();
296
+ return /^[A-Za-z0-9._:[\]-]+$/.test(v) ? v : null;
297
+ } catch { return null; }
298
+ }
291
299
  /** forge.yaml `lean.explore` (on|off) and `lean.explore_model` (#4391): the builder's explorer subagent; undefined when neither is set or valid. */
292
300
  export function readExplore(cwd = process.cwd()) {
293
301
  try {
@@ -1031,7 +1039,7 @@ export async function resumeStalledFromCli(argv, deps = {}) {
1031
1039
  await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
1032
1040
  await assertLaneNotDefault(io, lane, repo);
1033
1041
  const dispatch = deps.dispatch ?? runFromCli;
1034
- const projector = makeProjector(io);
1042
+ const projector = makeProjector(io, { repo: repo ?? undefined });
1035
1043
  const now = Date.now();
1036
1044
 
1037
1045
  // Collect candidate issue numbers from all non-terminal workflow labels
@@ -1247,7 +1255,7 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
1247
1255
  for (const n of new Set(ints)) {
1248
1256
  let st = null;
1249
1257
  let v = null;
1250
- try { v = JSON.parse(await io.gh(["issue", "view", String(n), ...(repo ? ["-R", repo] : []), "--json", "state,body,author,authorAssociation"])); st = v?.state ?? null; } catch { st = null; }
1258
+ try { v = await viewIssue(io.gh, repo, n, "state,body,author"); st = v?.state ?? null; } catch { st = null; }
1251
1259
  state.set(n, st); records.set(n, v);
1252
1260
  }
1253
1261
  const open = [];
@@ -1854,8 +1862,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1854
1862
  sink.line(`cross-lane: #${e.issue} (${e.lane}) depends on #${e.dependency} (${e.dependencyLane}); its fix will not be on ${e.lane}: re-milestone #${e.dependency} or wait for a milestone sync`);
1855
1863
  },
1856
1864
  readBody: maint || registry || hybrid
1857
- ? async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,labels,comments,author,authorAssociation"])); trustedOf.set(n, bodyTrusted(v)); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }
1858
- : async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,comments,author,authorAssociation"])); trustedOf.set(n, bodyTrusted(v)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }, // #4785: decisions and the frozen contract's validate commands too
1865
+ ? async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,labels,comments,author"); trustedOf.set(n, bodyTrusted(v)); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }
1866
+ : async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,comments,author"); trustedOf.set(n, bodyTrusted(v)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }, // #4785: decisions and the frozen contract's validate commands too
1859
1867
  onUpdate: (r) => sink.row(r),
1860
1868
  });
1861
1869
  } finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
@@ -2219,7 +2227,7 @@ export async function schedFromCli(argv, deps = {}) {
2219
2227
  throw new Error(usage);
2220
2228
  }
2221
2229
 
2222
- /** #4773: is the author of a `gh issue view --json author,authorAssociation` record trusted (the engine admission's `isTrustedActor`)? */
2230
+ /** #4773: is the author of a `viewIssue` record (author plus the REST association, #4859) trusted (the engine admission's `isTrustedActor`)? */
2223
2231
  const bodyTrusted = (v) => isTrustedActor({ association: v?.authorAssociation, type: v?.author?.is_bot ? "Bot" : "User", login: v?.author?.login });
2224
2232
 
2225
2233
  /**
package/bin/forgedock.mjs CHANGED
@@ -3360,6 +3360,7 @@ function help() {
3360
3360
  ["npx forgedock milestone train <slug...> [--parallel-milestones N] | --resume <id> | --insert <id> <slug> [--before s|--after s] | --show <id>", "Unattended multi-milestone train: one durable state file (order, active set, lifecycle), typed STOP reasons, resumable; never edits forge.yaml"],
3361
3361
  ["npx forgedock guards list [--path p] [--json] | backfill --since <date> [--dry-run]", "Fix guards: list the FORGE:GUARD records (test files + protected hunks of merged fixes) covering a path, or build them for already-merged lean lanes"],
3362
3362
  ["npx forgedock issue-watch run [--name <w>] [--repo owner/repo] [--once] [--dry-run] | status [--name <w>]", "Named, scoped, capped watches (forge.yaml issue_watch.watches) that claim matching open issues into lanes or one long-lived orchestrate batch, or (mode: release) pool them and cut themed releases; see docs/ISSUE-WATCH.md"],
3363
+ ["npx forgedock prod-watch run [--repo owner/repo] [--once] [--interval <s>] [--dry-run] | status", "Production-side watcher (forge.yaml prod_watch): read-only signal sources -> triage -> governor -> issues, kill-switch file, owner digest; see docs/PROD-WATCH.md"],
3363
3364
  ["npx forgedock limits show | clear <id>|--all", "Show or clear per-account usage-limit holds shared by orchestrate batches"],
3364
3365
  ["npx forgedock decisions list [--stale] [--refresh]", "List anchored decisions (FORGE:DECISION); --stale shows those with an anchor missing on the base"],
3365
3366
  ["npx forgedock sched show | cap <N>|--clear | weight <owner/repo> <N>|--clear", "Show the host lane registry, set the host-wide lane cap shared by orchestrate batches, or set a repo's share of slots within a priority (default equal)"],
@@ -3946,7 +3947,7 @@ async function watch() {
3946
3947
  const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "probe", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3947
3948
  const KNOWN_COMMANDS = new Set([
3948
3949
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3949
- "run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "issue-watch", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "help", "--help", "-h",
3950
+ "run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "issue-watch", "prod-watch", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "help", "--help", "-h",
3950
3951
  "version", "--version", "-v",
3951
3952
  ]);
3952
3953
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
@@ -4217,6 +4218,16 @@ switch (command) {
4217
4218
  }
4218
4219
  break;
4219
4220
  }
4221
+ case "prod-watch": {
4222
+ const { prodWatchFromCli } = await import("./lean/prodwatch.mjs");
4223
+ try {
4224
+ await prodWatchFromCli(restArgs);
4225
+ } catch (err) {
4226
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4227
+ exitCode = 1;
4228
+ }
4229
+ break;
4230
+ }
4220
4231
  case "limits": {
4221
4232
  const { limitsFromCli } = await import("./engine-cli.mjs");
4222
4233
  try {
@@ -538,7 +538,7 @@ describe("#4408: issues added to a running batch (forgedock orchestrate --add)",
538
538
  assert.deepEqual(b.readAdded(), [2, 3, 5]);
539
539
  assert.deepEqual(b.unrunAdds(), [2, 3, 5]);
540
540
  const views = [];
541
- const io = { gh: async (a) => { views.push(a); if (a[2] === "8") throw new Error("gh down"); return JSON.stringify({ state: a[2] === "7" ? "CLOSED" : "OPEN" }); } };
541
+ const io = { gh: async (a) => { if (a[0] === "api") return "OWNER\n"; views.push(a); if (a[2] === "8") throw new Error("gh down"); return JSON.stringify({ state: a[2] === "7" ? "CLOSED" : "OPEN" }); } };
542
542
  const res = await quiet(() => orchestrateFromCli(["--add", "b1", "6", "7", "8", "3", "x"], { batchRoot: root, io }));
543
543
  assert.deepEqual(res.added, [6]);
544
544
  assert.deepEqual(res.skipped.map((x) => [x.issue, x.reason]).sort(), [[3, "already in batch"], [7, "closed"], [8, "unreadable"], ["x", "not an integer"]]);
@@ -553,7 +553,7 @@ describe("#4408: issues added to a running batch (forgedock orchestrate --add)",
553
553
  b.writePlan({ argv: [], repo: "o/r" }); // no issues: the resume stops with the usage error after its adds
554
554
  b.store.save({ order: [1] });
555
555
  const views = [];
556
- const io = { gh: async (a) => { views.push(a[2]); return JSON.stringify({ state: "OPEN" }); } };
556
+ const io = { gh: async (a) => { if (a[0] === "api") return "OWNER\n"; views.push(a[2]); return JSON.stringify({ state: "OPEN" }); } };
557
557
  await assert.rejects(quiet(() => orchestrateFromCli(["--resume", "b1", "--parallel", "5"], { batchRoot: root, io })), /usage/);
558
558
  assert.deepEqual(views, [], "no issue read for a flag value");
559
559
  assert.deepEqual(b.readAdded(), []);
@@ -8,8 +8,8 @@ import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
8
8
  import { promises as fsp } from "node:fs";
9
9
  import { dirname, join, resolve, sep } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
- import { runCliBackendAsync, resolveClaudeCliBinary, getClaudeCliCapabilities, buildLeanSessionFlags, buildReadOnlyToolFlags } from "../runner.mjs";
12
- import { makeIo } from "../engine-cli.mjs";
11
+ import { runCliBackendAsync, resolveClaudeCliBinary, getClaudeCliCapabilities, buildLeanSessionFlags, buildReadOnlyToolFlags, sanitizeCliModel } from "../runner.mjs";
12
+ import { makeIo, readSubagentModel } from "../engine-cli.mjs";
13
13
  import { makeProjector } from "../engine/projector.mjs";
14
14
  import { makeLedger, countLiveLanes, ledgerRoot } from "./ledger.mjs";
15
15
  import { makeDepCache } from "./depcache.mjs";
@@ -76,21 +76,39 @@ export const noToolFlags = (caps) => (caps?.strictMcp ? ["--tools", ""]
76
76
 
77
77
  /** #4456: `--settings` registering the re-read nudge (bin/hooks/reread-nudge.mjs) as a PostToolUse hook on Read and Bash. */
78
78
  export const REREAD_HOOK = fileURLToPath(new URL("../hooks/reread-nudge.mjs", import.meta.url));
79
- export const rereadNudgeFlags = (node = process.execPath, hook = REREAD_HOOK) => {
79
+ export const rereadHooks = (node = process.execPath, hook = REREAD_HOOK) => {
80
80
  const hooks = [{ type: "command", command: `${JSON.stringify(node)} ${JSON.stringify(hook)}` }];
81
81
  // #4580: the same script denies a full-file Read of an unchanged path already read twice (PreToolUse)
82
- return ["--settings", JSON.stringify({ hooks: { PostToolUse: [{ matcher: "Read|Bash", hooks }], PreToolUse: [{ matcher: "Read", hooks }] } })];
82
+ return { PostToolUse: [{ matcher: "Read|Bash", hooks }], PreToolUse: [{ matcher: "Read", hooks }] };
83
83
  };
84
+ export const rereadNudgeFlags = (node = process.execPath, hook = REREAD_HOOK) => ["--settings", JSON.stringify({ hooks: rereadHooks(node, hook) })];
84
85
 
85
- export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable } = {}) {
86
+ /**
87
+ * #4843: ForgeDock owns the subagent model of the sessions it launches. An operator settings.json `env` such as
88
+ * CLAUDE_CODE_SUBAGENT_MODEL=sonnet + _FORCE=1 would otherwise override every subagent, the #4391 Haiku explorer
89
+ * included, and a child env var cannot undo it (settings env wins); a `--settings` flag does. FORCE=0 lets a
90
+ * ForgeDock-defined agent's own `model` win. CLAUDE_CODE_SUBAGENT_MODEL (the default for agents without one) is
91
+ * set only from forge.yaml `agents.subagent_model`; unset leaves the operator's own default alone (#4849).
92
+ */
93
+ export const ownedModelEnv = (subagentModel = null) => {
94
+ const m = sanitizeCliModel(subagentModel);
95
+ return { ...(m ? { CLAUDE_CODE_SUBAGENT_MODEL: m } : {}), CLAUDE_CODE_SUBAGENT_MODEL_FORCE: "0" };
96
+ };
97
+ /** The one `--settings` of a lean session: the owned model env, plus the re-read hooks for builder sessions (#4456). */
98
+ export const leanSettingsFlags = ({ subagentModel = null, reread = false } = {}) =>
99
+ ["--settings", JSON.stringify({ env: ownedModelEnv(subagentModel), ...(reread ? { hooks: rereadHooks() } : {}) })];
100
+
101
+ export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable, subagentModel = null } = {}) {
86
102
  return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, rereadNudge = false, maxTurns = null, agents = null, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null }) => {
87
103
  const bin = resolveClaudeCliBinary(cwd) || "claude";
88
104
  const caps = getClaudeCliCapabilities(cwd);
89
105
  // #4391: custom subagents (the explorer) for the builder session; null leaves argv unchanged
90
- const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : []), ...(agents ? ["--agents", JSON.stringify(agents)] : []),
91
- ...(rereadNudge && !readOnly && !noTools ? rereadNudgeFlags() : [])]; // #4456: builder sessions only
106
+ const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : []), ...(agents ? ["--agents", JSON.stringify(agents)] : [])];
107
+ // #4843 owned models, in extraArgs so FORGEDOCK_FULL_SESSION=1 (which drops sessionFlags) keeps them (#4848 BUG-1);
108
+ // #4456 re-read hooks: builder sessions only, and like the other session flags not in a full session
109
+ const settings = leanSettingsFlags({ subagentModel, reread: rereadNudge && !readOnly && !noTools && process.env.FORGEDOCK_FULL_SESSION !== "1" });
92
110
  const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
93
- args: [String(issue)], cwd, bin, model, maxTurns: caps?.maxTurns ? maxTurns : null, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
111
+ args: [String(issue)], cwd, bin, model, maxTurns: caps?.maxTurns ? maxTurns : null, env, sessionFlags: flags, extraArgs: [...(resume ? ["--resume", resume] : []), ...settings],
94
112
  logger: { log() {} },
95
113
  idleMs, maxMs, stream: idleMs > 0, control, onSpawn, busy, busyMs, onIdleExtend, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}), wrap: probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
96
114
  // #4395: a resumed session's first turn carries the prior history, so only a fresh session reports a baseline.
@@ -275,12 +293,12 @@ export function makeGhCache({ root = null, tmp = tmpdir() } = {}) {
275
293
  };
276
294
  }
277
295
 
278
- export function makeLeanIo({ repo }) {
296
+ export function makeLeanIo({ repo, subagentModel = readSubagentModel() }) {
279
297
  const base = makeIo();
280
298
  const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000, ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}) })).stdout;
281
299
  return {
282
300
  ghCache: (root) => makeGhCache({ root }),
283
- gh: run("gh"), git: run("git"), docker: run("docker"), killGroup: async (pid, marker) => killLedgerPid(pid, marker), ledger: (opts) => makeLedger(opts), runTmp: makeRunTmp(), host: makeHostProbe(), heavy: makeHeavyIo({ host: makeHostProbe() }), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
301
+ gh: run("gh"), git: run("git"), docker: run("docker"), killGroup: async (pid, marker) => killLedgerPid(pid, marker), ledger: (opts) => makeLedger(opts), runTmp: makeRunTmp(), host: makeHostProbe(), heavy: makeHeavyIo({ host: makeHostProbe() }), projector: makeProjector({ gh: base.gh }, { repo }), runner: makeLeanRunner({ subagentModel }), waitCiGreen: base.waitCiGreen,
284
302
  /** Shared dependency cache (#4220): the repo's own package manager, argv only, bounded by the cache's timeout. */
285
303
  deps: makeDepCache({ exec: async (cmd, args, { cwd, env, timeoutMs } = {}) => String((await pexec(cmd, args, { cwd, env: { ...process.env, ...(env ?? {}) }, timeout: timeoutMs ?? 0, maxBuffer: 100 * 1024 * 1024 })).stdout) }),
286
304
  /** Names of the checks the base branch requires: classic protection plus rulesets (#4158). [] when neither has any. */
@@ -42,7 +42,7 @@ describe("explorer subagent config (#4391)", () => {
42
42
  const [plain, withAgents] = seen;
43
43
  assert.ok(!plain.includes("--agents"));
44
44
  const i = withAgents.indexOf("--agents");
45
- assert.deepEqual(withAgents.slice(0, i), plain);
45
+ assert.deepEqual([...withAgents.slice(0, i), ...withAgents.slice(i + 2)], plain, "only the --agents pair is added");
46
46
  assert.deepEqual(JSON.parse(withAgents[i + 1]), agents);
47
47
  });
48
48
 
@@ -0,0 +1,85 @@
1
+ {
2
+ "memos": 70,
3
+ "clusters": [
4
+ {
5
+ "name": "pg-conn",
6
+ "class": "defect",
7
+ "count": 15
8
+ },
9
+ {
10
+ "name": "api-5xx",
11
+ "class": "incident",
12
+ "count": 1
13
+ },
14
+ {
15
+ "name": "queue-lag",
16
+ "class": "defect",
17
+ "count": 1
18
+ },
19
+ {
20
+ "name": "cert",
21
+ "class": "defect",
22
+ "count": 1
23
+ },
24
+ {
25
+ "name": "disk",
26
+ "class": "defect",
27
+ "count": 1
28
+ },
29
+ {
30
+ "name": "deploy",
31
+ "class": "deploy-fallout",
32
+ "count": 1
33
+ },
34
+ {
35
+ "name": "mon-bug",
36
+ "class": "monitoring-bug",
37
+ "count": 1
38
+ },
39
+ {
40
+ "name": "mem-leak",
41
+ "class": "defect",
42
+ "count": 1
43
+ },
44
+ {
45
+ "name": "t1",
46
+ "class": "transient",
47
+ "count": 1
48
+ },
49
+ {
50
+ "name": "t2",
51
+ "class": "transient",
52
+ "count": 1
53
+ },
54
+ {
55
+ "name": "t3",
56
+ "class": "transient",
57
+ "count": 1
58
+ },
59
+ {
60
+ "name": "t4",
61
+ "class": "transient",
62
+ "count": 1
63
+ },
64
+ {
65
+ "name": "e1",
66
+ "class": "expected-class",
67
+ "count": 11
68
+ },
69
+ {
70
+ "name": "e2",
71
+ "class": "expected-class",
72
+ "count": 11
73
+ },
74
+ {
75
+ "name": "e3",
76
+ "class": "expected-class",
77
+ "count": 11
78
+ },
79
+ {
80
+ "name": "e4",
81
+ "class": "expected-class",
82
+ "count": 11
83
+ }
84
+ ]
85
+ }