karajan-code 3.13.0 → 3.14.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/package.json +1 -1
- package/src/cli/register-pipeline.js +2 -0
- package/src/commands/init.js +9 -0
- package/src/config/defaults.js +10 -1
- package/src/config/overrides.js +12 -2
- package/src/orchestrator/drivers/iteration-loop.js +30 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +5 -2
- package/src/orchestrator/flow-control.js +5 -1
- package/src/orchestrator/hu-scheduler.js +114 -0
- package/src/orchestrator/hu-sub-pipeline.js +42 -10
- package/src/orchestrator/iteration-gate.js +73 -0
- package/src/orchestrator/parallel-limiter.js +74 -0
- package/src/utils/async-lock.js +31 -0
- package/templates/kj.config.yml +3 -1
package/package.json
CHANGED
|
@@ -101,6 +101,8 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
101
101
|
.option("--enable-sonarcloud", "Enable SonarCloud scan (complementary to SonarQube)")
|
|
102
102
|
.option("--no-sonarcloud")
|
|
103
103
|
.option("--checkpoint-interval <n>", "Minutes between interactive checkpoints (default: 5)")
|
|
104
|
+
.option("--step", "Pause after each iteration with a report and ask before continuing")
|
|
105
|
+
.option("--parallel <n>", "Concurrent HU lanes for plan runs (default: 1, sequential)")
|
|
104
106
|
.option("--pg-task <cardId>", "Planning Game card ID (e.g., KJC-TSK-0042)")
|
|
105
107
|
.option("--pg-project <projectId>", "Planning Game project ID")
|
|
106
108
|
.option("--auto-simplify", "Auto-simplify pipeline for simple tasks (disable reviewer/tester)")
|
package/src/commands/init.js
CHANGED
|
@@ -371,6 +371,15 @@ async function runWizard(config, logger) {
|
|
|
371
371
|
logger.info("Git automation:");
|
|
372
372
|
await askGitAutomation(wizard, config, logger);
|
|
373
373
|
|
|
374
|
+
// Iteration gate (KJC-TSK-0628): opt-in step mode, default no.
|
|
375
|
+
config.session = config.session || {};
|
|
376
|
+
const stepMode = await wizard.confirm(
|
|
377
|
+
"Pause after each iteration with a report and ask before continuing (step mode)?",
|
|
378
|
+
false,
|
|
379
|
+
);
|
|
380
|
+
config.session.iteration_gate = stepMode;
|
|
381
|
+
logger.info(` -> iteration_gate: ${stepMode}`);
|
|
382
|
+
|
|
374
383
|
if (enableHuBoard) {
|
|
375
384
|
logger.info("");
|
|
376
385
|
logger.info("HU Board security:");
|
package/src/config/defaults.js
CHANGED
|
@@ -55,7 +55,10 @@ const DEFAULTS = {
|
|
|
55
55
|
review_mode: "standard",
|
|
56
56
|
max_iterations: 5,
|
|
57
57
|
hu_max_iterations: 3,
|
|
58
|
-
|
|
58
|
+
// Hard per-run spend ceiling, ON by default (KJC-TSK-0621): a stuck or
|
|
59
|
+
// runaway run must never drain a subscription quota unattended. Explicit
|
|
60
|
+
// null in the user's config opts out (no cap).
|
|
61
|
+
max_budget_usd: 5,
|
|
59
62
|
review_rules: "./.karajan/review-rules.md",
|
|
60
63
|
coder_rules: "./.karajan/coder-rules.md",
|
|
61
64
|
base_branch: "main",
|
|
@@ -173,6 +176,12 @@ const DEFAULTS = {
|
|
|
173
176
|
role_overrides: {}
|
|
174
177
|
},
|
|
175
178
|
session: {
|
|
179
|
+
// Opt-in per-iteration supervision (KJC-TSK-0628): pause after each
|
|
180
|
+
// iteration with a report and ask continue / stop / instructions.
|
|
181
|
+
iteration_gate: false,
|
|
182
|
+
// Concurrent HU lanes per plan run (KJC-TSK-0626). 1 = sequential.
|
|
183
|
+
// Raising it multiplies token burn rate — the plan budget scales with it.
|
|
184
|
+
max_parallel_hus: 1,
|
|
176
185
|
max_iteration_minutes: 30,
|
|
177
186
|
max_total_minutes: 120,
|
|
178
187
|
max_planner_minutes: 60,
|
package/src/config/overrides.js
CHANGED
|
@@ -55,6 +55,7 @@ const SCALAR_FLAGS = [
|
|
|
55
55
|
["maxIterationMinutes", (out, v) => { out.session.max_iteration_minutes = Number(v); }],
|
|
56
56
|
["maxTotalMinutes", (out, v) => { out.session.max_total_minutes = Number(v); }],
|
|
57
57
|
["checkpointInterval", (out, v) => { out.session.checkpoint_interval_minutes = Number(v); }],
|
|
58
|
+
["parallel", (out, v) => { out.session.max_parallel_hus = Number(v); }],
|
|
58
59
|
["baseBranch", (out, v) => { out.base_branch = v; }],
|
|
59
60
|
["coderFallback", (out, v) => { out.coder_options.fallback_coder = v; }],
|
|
60
61
|
["reviewerFallback", (out, v) => { out.reviewer_options.fallback_reviewer = v; }],
|
|
@@ -147,6 +148,9 @@ function applyMiscOverrides(out, flags) {
|
|
|
147
148
|
// The flag is still accepted to avoid breaking scripts; it now emits a
|
|
148
149
|
// warning at run start and is otherwise ignored. See preflight-checks.js
|
|
149
150
|
// and flow-runner.js for the actual gate (resolvedPolicies.sonar).
|
|
151
|
+
// --step: per-iteration supervision gate (KJC-TSK-0628).
|
|
152
|
+
if (flags.step === true) out.session.iteration_gate = true;
|
|
153
|
+
|
|
150
154
|
if (flags.noSonar || flags.sonar === false) {
|
|
151
155
|
out._deprecated = out._deprecated || {};
|
|
152
156
|
out._deprecated.noSonarFlag = true;
|
|
@@ -172,8 +176,14 @@ export function applyRunOverrides(config, flags) {
|
|
|
172
176
|
out.git = out.git || {};
|
|
173
177
|
out.development = out.development || {};
|
|
174
178
|
out.sonarqube = out.sonarqube || {};
|
|
175
|
-
|
|
176
|
-
|
|
179
|
+
// Precedence: explicit top-level value (including null = opt-out) wins;
|
|
180
|
+
// the legacy session.max_budget_usd location next; the shipped default
|
|
181
|
+
// (KJC-TSK-0621: 5) last. When top-level still carries the default, a
|
|
182
|
+
// session-level value — including an explicit null — takes over.
|
|
183
|
+
if (out.max_budget_usd === undefined) {
|
|
184
|
+
out.max_budget_usd = out.session.max_budget_usd !== undefined ? out.session.max_budget_usd : DEFAULTS.max_budget_usd;
|
|
185
|
+
} else if (out.max_budget_usd === DEFAULTS.max_budget_usd && out.session.max_budget_usd !== undefined) {
|
|
186
|
+
out.max_budget_usd = out.session.max_budget_usd;
|
|
177
187
|
}
|
|
178
188
|
out.budget = mergeDeep(DEFAULTS.budget, out.budget || {});
|
|
179
189
|
out.roles = mergeDeep(DEFAULTS.roles, out.roles || {});
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
setReviewerFeedback, resetRetryCount,
|
|
28
28
|
} from "../../session/mutators.js";
|
|
29
29
|
import { invokeSolomon } from "../solomon-escalation.js";
|
|
30
|
+
import { buildIterationReport, handleIterationGate } from "../iteration-gate.js";
|
|
30
31
|
import {
|
|
31
32
|
handleCiEarlyPrOrPush, handleCiReviewDispatch,
|
|
32
33
|
} from "../ci-integration.js";
|
|
@@ -251,6 +252,21 @@ export async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitt
|
|
|
251
252
|
return iterResult.result;
|
|
252
253
|
}
|
|
253
254
|
if (iterResult.action === "retry") { i -= 1; }
|
|
255
|
+
else {
|
|
256
|
+
// Iteration gate (KJC-TSK-0628): opt-in pause with a report before the
|
|
257
|
+
// next iteration; free-text answers become directives for the coder.
|
|
258
|
+
const gate = await handleIterationGate({
|
|
259
|
+
enabled: ctx.config.session?.iteration_gate === true,
|
|
260
|
+
askQuestion, session: ctx.session, logger,
|
|
261
|
+
report: buildIterationReport({
|
|
262
|
+
i, maxIterations: ctx.config.max_iterations, stageResults: ctx.stageResults,
|
|
263
|
+
budgetTracker: ctx.budgetTracker, maxBudgetUsd: ctx.config.max_budget_usd,
|
|
264
|
+
}),
|
|
265
|
+
});
|
|
266
|
+
if (gate.action === "stop") {
|
|
267
|
+
return { approved: false, sessionId: ctx.session.id, reason: "user_stopped_at_gate", iteration: i };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
254
270
|
}
|
|
255
271
|
|
|
256
272
|
// Solomon decides whether to extend iterations or stop
|
|
@@ -289,6 +305,20 @@ export async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitt
|
|
|
289
305
|
const iterResult = await runSingleIteration(ctx);
|
|
290
306
|
if (iterResult.action === "return") return iterResult.result;
|
|
291
307
|
if (iterResult.action === "retry") { i -= 1; }
|
|
308
|
+
else {
|
|
309
|
+
// Same iteration gate on Solomon-extended iterations (KJC-TSK-0628).
|
|
310
|
+
const gate = await handleIterationGate({
|
|
311
|
+
enabled: ctx.config.session?.iteration_gate === true,
|
|
312
|
+
askQuestion, session: ctx.session, logger,
|
|
313
|
+
report: buildIterationReport({
|
|
314
|
+
i, maxIterations: ctx.config.max_iterations, stageResults: ctx.stageResults,
|
|
315
|
+
budgetTracker: ctx.budgetTracker, maxBudgetUsd: ctx.config.max_budget_usd,
|
|
316
|
+
}),
|
|
317
|
+
});
|
|
318
|
+
if (gate.action === "stop") {
|
|
319
|
+
return { approved: false, sessionId: ctx.session.id, reason: "user_stopped_at_gate", iteration: i };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
292
322
|
}
|
|
293
323
|
|
|
294
324
|
// Extended iterations also exhausted — final Solomon call
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { generateDiff } from "../../../review/diff-generator.js";
|
|
17
|
+
import { withLock } from "../../../utils/async-lock.js";
|
|
17
18
|
import {
|
|
18
19
|
runTddCheckStage, runSonarStage, runSonarCloudStage,
|
|
19
20
|
} from "../../iteration-stages.js";
|
|
@@ -52,10 +53,12 @@ export async function runQualityGateStages({ config, logger, emitter, eventBase,
|
|
|
52
53
|
// that one file.
|
|
53
54
|
const sonarStageDisabledForTest = config?.testHarness?.disableSonarStage === true;
|
|
54
55
|
if (!sonarStageDisabledForTest && session.resolved_policies?.sonar !== false) {
|
|
55
|
-
|
|
56
|
+
// Serialize across parallel HU lanes (KJC-TSK-0625): the SonarQube
|
|
57
|
+
// server scans one project key at a time. No-op on sequential runs.
|
|
58
|
+
const sonarResult = await withLock("sonar-scan", () => runSonarStage({
|
|
56
59
|
config, logger, emitter, eventBase, session, trackBudget, iteration: i,
|
|
57
60
|
repeatDetector, budgetSummary, sonarState, askQuestion, task, brainCtx
|
|
58
|
-
});
|
|
61
|
+
}));
|
|
59
62
|
if (sonarResult.action === "stalled" || sonarResult.action === "pause") return { action: "return", result: sonarResult.result };
|
|
60
63
|
if (sonarResult.action === "continue") return { action: "continue" };
|
|
61
64
|
if (sonarResult.stageResult) {
|
|
@@ -153,7 +153,11 @@ export async function checkBudgetExceeded({ budgetTracker, config, session, emit
|
|
|
153
153
|
|
|
154
154
|
await markSessionStatus(session, "failed");
|
|
155
155
|
const totalCost = budgetTracker.total().cost_usd;
|
|
156
|
-
const
|
|
156
|
+
const limit = Number(budgetLimit ?? config?.max_budget_usd);
|
|
157
|
+
const message =
|
|
158
|
+
`Budget exceeded: $${totalCost.toFixed(2)} > $${limit.toFixed(2)}. ` +
|
|
159
|
+
`Continue consciously with \`kj resume ${session.id}\`, or raise the cap ` +
|
|
160
|
+
`(max_budget_usd in .karajan/kj.config.yml; null removes it).`;
|
|
157
161
|
emitProgress(
|
|
158
162
|
emitter,
|
|
159
163
|
makeEvent("session:end", { ...eventBase, iteration: i, stage: "budget" }, {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hu-scheduler — pure scheduling for parallel HU execution (KJC-TSK-0623,
|
|
3
|
+
* épica KJC-PCS-0065). Decides WHICH HUs may run concurrently; it never
|
|
4
|
+
* launches anything. Two rules, both conservative by design:
|
|
5
|
+
*
|
|
6
|
+
* 1. Dependencies: every blocked_by id must be in completedIds.
|
|
7
|
+
* 2. Scope isolation: two HUs whose scopes touch the same path prefix are
|
|
8
|
+
* never scheduled together — predictable merge conflicts cost more than
|
|
9
|
+
* the parallelism they'd buy. An HU with NO scope has an unknown blast
|
|
10
|
+
* radius, so it runs alone (exclusive with everything).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract path-like tokens from a free-form scope. The planner writes scope
|
|
15
|
+
* as prose ("src/auth y tests/auth", "docs/*.md"); we keep anything that
|
|
16
|
+
* looks like a path or glob, normalized to its base directory.
|
|
17
|
+
*/
|
|
18
|
+
export function scopeTokens(scope) {
|
|
19
|
+
if (!scope || typeof scope !== "string") return [];
|
|
20
|
+
const matches = scope.match(/[\w.-]+(?:\/[\w*.{}-]+)+|[\w.-]+\/(?=\s|$|,)/g) || [];
|
|
21
|
+
return matches
|
|
22
|
+
.map((raw) => {
|
|
23
|
+
// Cut the token at its first glob segment: "src/**/*.js" → "src".
|
|
24
|
+
const segments = raw.replace(/\/+$/, "").split("/");
|
|
25
|
+
const firstGlob = segments.findIndex((s) => s.includes("*") || s.includes("{"));
|
|
26
|
+
return (firstGlob === -1 ? segments : segments.slice(0, firstGlob)).join("/");
|
|
27
|
+
})
|
|
28
|
+
.filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True when two token lists share a path-prefix relationship. */
|
|
32
|
+
function tokensOverlap(a, b) {
|
|
33
|
+
for (const ta of a) {
|
|
34
|
+
for (const tb of b) {
|
|
35
|
+
if (ta === tb || ta.startsWith(`${tb}/`) || tb.startsWith(`${ta}/`)) return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* True when two HUs cannot run concurrently: either one has no parseable
|
|
43
|
+
* scope (unknown blast radius ⇒ exclusive) or their scopes overlap.
|
|
44
|
+
*/
|
|
45
|
+
export function husConflict(huA, huB) {
|
|
46
|
+
const a = scopeTokens(huA.scope);
|
|
47
|
+
const b = scopeTokens(huB.scope);
|
|
48
|
+
if (a.length === 0 || b.length === 0) return true;
|
|
49
|
+
return tokensOverlap(a, b);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Split one dependency-level group into conflict-free chunks of at most
|
|
54
|
+
* `maxParallel` HUs (KJC-TSK-0626). Every chunk runs concurrently; chunks
|
|
55
|
+
* run one after another. maxParallel 1 ⇒ singletons (fully sequential).
|
|
56
|
+
* Same conservative rules as husConflict: no parseable scope ⇒ runs alone.
|
|
57
|
+
*
|
|
58
|
+
* @param {Array<{id: string, scope?: string|null}>} stories - full story list
|
|
59
|
+
* @param {string[]} groupIds - ids of one dependency-level group, in order
|
|
60
|
+
* @param {number} maxParallel
|
|
61
|
+
* @returns {string[][]} chunks of ids
|
|
62
|
+
*/
|
|
63
|
+
export function partitionConflictFree(stories, groupIds, maxParallel = 1) {
|
|
64
|
+
if (maxParallel <= 1) return groupIds.map((id) => [id]);
|
|
65
|
+
const byId = new Map(stories.map((s) => [s.id, s]));
|
|
66
|
+
const chunks = [];
|
|
67
|
+
for (const id of groupIds) {
|
|
68
|
+
const story = byId.get(id) ?? { id };
|
|
69
|
+
const fit = chunks.find(
|
|
70
|
+
(chunk) => chunk.length < maxParallel && !chunk.some((otherId) => husConflict(story, byId.get(otherId) ?? { id: otherId })),
|
|
71
|
+
);
|
|
72
|
+
if (fit) fit.push(id);
|
|
73
|
+
else chunks.push([id]);
|
|
74
|
+
}
|
|
75
|
+
return chunks;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Select the next HUs to launch, order-preserving and greedy.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} args
|
|
82
|
+
* @param {Array<{id: string, blocked_by?: string[], scope?: string|null}>} args.hus - candidates, in plan order
|
|
83
|
+
* @param {string[]} [args.completedIds] - HUs already done (dependency source)
|
|
84
|
+
* @param {Array<{id: string, scope?: string|null}>} [args.inFlight] - HUs currently running
|
|
85
|
+
* @param {number} [args.maxParallel] - total concurrency cap (in-flight included)
|
|
86
|
+
* @returns {{ selected: object[], blocked: Array<{id: string, reason: string}> }}
|
|
87
|
+
*/
|
|
88
|
+
export function selectRunnableHus({ hus = [], completedIds = [], inFlight = [], maxParallel = 1 } = {}) {
|
|
89
|
+
const done = new Set(completedIds);
|
|
90
|
+
const flying = new Set(inFlight.map((h) => h.id));
|
|
91
|
+
const slots = Math.max(0, maxParallel - inFlight.length);
|
|
92
|
+
const selected = [];
|
|
93
|
+
const blocked = [];
|
|
94
|
+
|
|
95
|
+
for (const hu of hus) {
|
|
96
|
+
if (flying.has(hu.id) || done.has(hu.id)) continue;
|
|
97
|
+
const missing = (hu.blocked_by || []).filter((dep) => !done.has(dep));
|
|
98
|
+
if (missing.length > 0) {
|
|
99
|
+
blocked.push({ id: hu.id, reason: `waiting for ${missing.join(", ")}` });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (selected.length >= slots) {
|
|
103
|
+
blocked.push({ id: hu.id, reason: "no free slot" });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const rival = [...inFlight, ...selected].find((other) => husConflict(hu, other));
|
|
107
|
+
if (rival) {
|
|
108
|
+
blocked.push({ id: hu.id, reason: `scope conflicts with ${rival.id}` });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
selected.push(hu);
|
|
112
|
+
}
|
|
113
|
+
return { selected, blocked };
|
|
114
|
+
}
|
|
@@ -7,6 +7,8 @@ import { updateStoryStatus, loadHuBatch, saveHuBatch, HU_STATUS } from "../hu/st
|
|
|
7
7
|
import { emitProgress, makeEvent } from "../utils/events.js";
|
|
8
8
|
import { refineHuWithContext } from "../hu/lazy-planner.js";
|
|
9
9
|
import { findParallelGroups, createWorktree, mergeWorktree, removeWorktree } from "../hu/parallel-executor.js";
|
|
10
|
+
import { partitionConflictFree } from "./hu-scheduler.js";
|
|
11
|
+
import { createParallelLimiter, planBudgetUsd } from "./parallel-limiter.js";
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Determine whether the HU reviewer result needs the sub-pipeline path.
|
|
@@ -459,15 +461,38 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
|
|
|
459
461
|
// --- Group HUs into parallel batches ---
|
|
460
462
|
const parallelBatches = findParallelGroups(certifiedStories, orderedIds);
|
|
461
463
|
|
|
462
|
-
|
|
464
|
+
// Governance (KJC-TSK-0626): the dependency groups above used to launch
|
|
465
|
+
// UNBOUNDED via Promise.all — a plan with N independent HUs ran N full
|
|
466
|
+
// pipelines at once, with no cap, no shared budget and no scope checks.
|
|
467
|
+
// Now each group is partitioned into conflict-free chunks of at most
|
|
468
|
+
// max_parallel_hus (default 1 = fully sequential, today's safe path) and
|
|
469
|
+
// a shared limiter gates every launch against the PLAN budget.
|
|
470
|
+
const maxParallel = Math.max(1, Number(config?.session?.max_parallel_hus ?? 1));
|
|
471
|
+
const limiter = createParallelLimiter({
|
|
472
|
+
maxParallel,
|
|
473
|
+
budgetTracker,
|
|
474
|
+
planBudgetUsd: planBudgetUsd({ maxBudgetUsd: config?.max_budget_usd, maxParallel }),
|
|
475
|
+
});
|
|
476
|
+
let stopReason = null;
|
|
477
|
+
|
|
478
|
+
outer: for (const group of parallelBatches) {
|
|
479
|
+
for (const chunk of partitionConflictFree(batch.stories, group, maxParallel)) {
|
|
463
480
|
// Filter out HUs that were blocked by a failed dependency in a previous batch
|
|
464
|
-
const runnableIds =
|
|
481
|
+
const runnableIds = chunk.filter(id => {
|
|
465
482
|
const story = batch.stories.find(s => s.id === id);
|
|
466
483
|
return story && story.status !== HU_STATUS.BLOCKED;
|
|
467
484
|
});
|
|
468
485
|
|
|
469
486
|
if (runnableIds.length === 0) continue;
|
|
470
487
|
|
|
488
|
+
const blockReason = limiter.launchBlockReason();
|
|
489
|
+
if (blockReason) {
|
|
490
|
+
stopReason = blockReason;
|
|
491
|
+
allApproved = false;
|
|
492
|
+
logger.warn(`HU sub-pipeline stopped before launching ${runnableIds.join(", ")}: ${blockReason}`);
|
|
493
|
+
break outer;
|
|
494
|
+
}
|
|
495
|
+
|
|
471
496
|
// Emit parallel batch start event
|
|
472
497
|
emitProgress(emitter, makeEvent("hu:parallel-start", { ...eventBase, stage: "hu-sub-pipeline" }, {
|
|
473
498
|
message: `Starting parallel batch of ${runnableIds.length} HU(s): ${runnableIds.join(", ")}`,
|
|
@@ -511,14 +536,20 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
|
|
|
511
536
|
}
|
|
512
537
|
}
|
|
513
538
|
|
|
514
|
-
// Run all HUs in the batch concurrently
|
|
539
|
+
// Run all HUs in the batch concurrently, each holding a limiter slot
|
|
540
|
+
// (belt-and-braces: chunks are already ≤ maxParallel).
|
|
515
541
|
const batchPromises = runnableIds.map(async (storyId) => {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
542
|
+
await limiter.acquire();
|
|
543
|
+
try {
|
|
544
|
+
return await runSingleHu({
|
|
545
|
+
storyId, batch, batchSessionId, runIterationFn,
|
|
546
|
+
emitter, eventBase, logger, config, results,
|
|
547
|
+
worktreePath: worktrees.get(storyId),
|
|
548
|
+
onStatusChange, onOutcome, budgetTracker
|
|
549
|
+
});
|
|
550
|
+
} finally {
|
|
551
|
+
limiter.release();
|
|
552
|
+
}
|
|
522
553
|
});
|
|
523
554
|
|
|
524
555
|
const batchResults = await Promise.all(batchPromises);
|
|
@@ -554,7 +585,8 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
|
|
|
554
585
|
|
|
555
586
|
await saveHuBatch(batchSessionId, batch);
|
|
556
587
|
}
|
|
588
|
+
}
|
|
557
589
|
}
|
|
558
590
|
|
|
559
|
-
return { approved: allApproved, results, blockedIds };
|
|
591
|
+
return { approved: allApproved, results, blockedIds, ...(stopReason ? { stopReason } : {}) };
|
|
560
592
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iteration-gate — opt-in per-iteration supervision (KJC-TSK-0628, épica
|
|
3
|
+
* KJC-PCS-0062). With `--step` (or session.iteration_gate in config) the
|
|
4
|
+
* pipeline pauses after EVERY iteration with a compact report — what
|
|
5
|
+
* happened, what the next iteration will do, spend vs cap — and asks the
|
|
6
|
+
* user: continue, stop, or continue WITH instructions. Free-text answers
|
|
7
|
+
* are appended to the reviewer feedback the coder reads next iteration
|
|
8
|
+
* (same channel Solomon's human guidance uses), never overwriting the
|
|
9
|
+
* reviewer's own must-fix list.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { markSessionStatus } from "../session/store.js";
|
|
13
|
+
import { setReviewerFeedback } from "../session/mutators.js";
|
|
14
|
+
|
|
15
|
+
const STOP_ANSWERS = new Set(["stop", "parar", "para", "no", "n", "4"]);
|
|
16
|
+
const CONTINUE_ANSWERS = new Set(["", "s", "si", "sí", "y", "yes", "1", "continue", "continuar"]);
|
|
17
|
+
|
|
18
|
+
/** Compact human report of the iteration that just finished. */
|
|
19
|
+
export function buildIterationReport({ i, maxIterations, stageResults = {}, budgetTracker, maxBudgetUsd, reviewVerdict }) {
|
|
20
|
+
const lines = [`Iteration ${i}/${maxIterations} finished.`];
|
|
21
|
+
const stages = Object.keys(stageResults);
|
|
22
|
+
if (stages.length > 0) lines.push(`Stages so far: ${stages.join(", ")}`);
|
|
23
|
+
|
|
24
|
+
const review = reviewVerdict ?? stageResults.reviewer ?? null;
|
|
25
|
+
const mustFix = review?.must_fix ?? review?.issues ?? [];
|
|
26
|
+
if (review?.approved === true) {
|
|
27
|
+
lines.push("Reviewer: APPROVED");
|
|
28
|
+
} else if (mustFix.length > 0) {
|
|
29
|
+
const shown = mustFix.slice(0, 3).map((f) => ` • ${typeof f === "string" ? f : f.title || f.description || JSON.stringify(f)}`);
|
|
30
|
+
lines.push(`Reviewer: rejected with ${mustFix.length} must-fix:`, ...shown);
|
|
31
|
+
if (mustFix.length > 3) lines.push(` … and ${mustFix.length - 3} more`);
|
|
32
|
+
lines.push(`Next iteration: the coder tackles those ${mustFix.length} must-fix.`);
|
|
33
|
+
} else {
|
|
34
|
+
lines.push("Next iteration: the coder retries against the pending feedback.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const spent = budgetTracker?.total?.().cost_usd ?? 0;
|
|
38
|
+
const cap = maxBudgetUsd != null ? ` / cap $${Number(maxBudgetUsd).toFixed(2)}` : "";
|
|
39
|
+
lines.push(`Spend: $${spent.toFixed(2)}${cap}`);
|
|
40
|
+
return lines.join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Append a user directive without clobbering the reviewer's feedback. */
|
|
44
|
+
export function appendUserDirective(session, text) {
|
|
45
|
+
const existing = session.last_reviewer_feedback || "";
|
|
46
|
+
const block = `## User directive (iteration gate)\n${text}`;
|
|
47
|
+
setReviewerFeedback(session, existing ? `${existing}\n\n${block}` : block);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ask the user at the gate. Returns { action: "continue" | "stop",
|
|
52
|
+
* injected?: string }. Disabled gate, missing askQuestion (nobody to ask)
|
|
53
|
+
* or unattended autonomous runs pass straight through.
|
|
54
|
+
*/
|
|
55
|
+
export async function handleIterationGate({ enabled, askQuestion, session, logger, report }) {
|
|
56
|
+
if (!enabled || !askQuestion || askQuestion.unattended === true) return { action: "continue" };
|
|
57
|
+
|
|
58
|
+
const answer = await askQuestion(
|
|
59
|
+
`${report}\n\nContinue? [Enter = yes | "stop" = stop | anything else = instructions for the next iteration]`,
|
|
60
|
+
);
|
|
61
|
+
const trimmed = (answer || "").trim();
|
|
62
|
+
const lower = trimmed.toLowerCase();
|
|
63
|
+
|
|
64
|
+
if (STOP_ANSWERS.has(lower)) {
|
|
65
|
+
await markSessionStatus(session, "stopped");
|
|
66
|
+
return { action: "stop" };
|
|
67
|
+
}
|
|
68
|
+
if (CONTINUE_ANSWERS.has(lower)) return { action: "continue" };
|
|
69
|
+
|
|
70
|
+
appendUserDirective(session, trimmed);
|
|
71
|
+
logger?.info?.("Iteration gate: user directive injected for the next iteration.");
|
|
72
|
+
return { action: "continue", injected: trimmed };
|
|
73
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parallel-limiter — shared concurrency coordinator for parallel HU
|
|
3
|
+
* execution (KJC-TSK-0624, épica KJC-PCS-0065). One instance per plan run,
|
|
4
|
+
* shared by every HU lane:
|
|
5
|
+
*
|
|
6
|
+
* - Semaphore: at most maxParallel HUs hold a slot (acquire/release).
|
|
7
|
+
* - Plan budget: the whole run shares ONE BudgetTracker; the launch gate
|
|
8
|
+
* refuses new lanes once aggregated spend reaches the plan ceiling
|
|
9
|
+
* (maxParallel × max_budget_usd — parallelism scales the cap, it never
|
|
10
|
+
* multiplies it silently).
|
|
11
|
+
* - Cooldown: when any lane hits a provider rate limit it calls
|
|
12
|
+
* notifyCooldown(ms); NEW launches pause until it expires while lanes
|
|
13
|
+
* already flying finish their work — parallel retry stampedes are the
|
|
14
|
+
* fastest way to burn a quota.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export function createParallelLimiter({ maxParallel = 1, budgetTracker = null, planBudgetUsd = null, now = Date.now } = {}) {
|
|
18
|
+
let inUse = 0;
|
|
19
|
+
let cooldownUntil = 0;
|
|
20
|
+
const waiters = [];
|
|
21
|
+
|
|
22
|
+
const overPlanBudget = () => {
|
|
23
|
+
if (planBudgetUsd == null || !budgetTracker) return false;
|
|
24
|
+
return (budgetTracker.total?.().cost_usd ?? 0) >= Number(planBudgetUsd);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const grantNext = () => {
|
|
28
|
+
while (waiters.length > 0 && inUse < maxParallel) {
|
|
29
|
+
inUse += 1;
|
|
30
|
+
waiters.shift()();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
/** Why a new lane may not launch right now; null = clear to go. */
|
|
36
|
+
launchBlockReason() {
|
|
37
|
+
if (overPlanBudget()) {
|
|
38
|
+
return `plan budget exhausted ($${(budgetTracker.total().cost_usd).toFixed(2)} ≥ $${Number(planBudgetUsd).toFixed(2)})`;
|
|
39
|
+
}
|
|
40
|
+
const waitMs = cooldownUntil - now();
|
|
41
|
+
if (waitMs > 0) return `provider cooldown for ${Math.ceil(waitMs / 1000)}s`;
|
|
42
|
+
return null;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
/** Wait for a free slot. Resolves when this lane may run. */
|
|
46
|
+
acquire() {
|
|
47
|
+
if (inUse < maxParallel) {
|
|
48
|
+
inUse += 1;
|
|
49
|
+
return Promise.resolve();
|
|
50
|
+
}
|
|
51
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
release() {
|
|
55
|
+
inUse = Math.max(0, inUse - 1);
|
|
56
|
+
grantNext();
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/** A lane hit a rate limit: pause NEW launches until it expires. */
|
|
60
|
+
notifyCooldown(ms) {
|
|
61
|
+
cooldownUntil = Math.max(cooldownUntil, now() + Math.max(0, ms));
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
stats() {
|
|
65
|
+
return { inUse, waiting: waiters.length, maxParallel, cooldownUntil };
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Plan-level ceiling: parallelism scales the per-run cap, explicitly. */
|
|
71
|
+
export function planBudgetUsd({ maxBudgetUsd, maxParallel = 1 }) {
|
|
72
|
+
if (maxBudgetUsd == null) return null;
|
|
73
|
+
return Number(maxBudgetUsd) * Math.max(1, maxParallel);
|
|
74
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* async-lock — named in-process mutex (KJC-TSK-0625, épica KJC-PCS-0065).
|
|
3
|
+
* Parallel HU lanes run inside ONE kj process; shared single-instance
|
|
4
|
+
* resources (the SonarQube server scans one project key at a time) must
|
|
5
|
+
* serialize across lanes. `withLock` chains callers per name in FIFO
|
|
6
|
+
* order; sequential runs pay nothing (the chain is always settled).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const locks = new Map(); // name -> { tail: Promise, holders: number }
|
|
10
|
+
|
|
11
|
+
/** Run `fn` exclusively among every caller sharing `name`. */
|
|
12
|
+
export async function withLock(name, fn) {
|
|
13
|
+
const entry = locks.get(name) ?? { tail: Promise.resolve(), holders: 0 };
|
|
14
|
+
entry.holders += 1;
|
|
15
|
+
locks.set(name, entry);
|
|
16
|
+
|
|
17
|
+
const previous = entry.tail;
|
|
18
|
+
let release;
|
|
19
|
+
entry.tail = new Promise((resolve) => {
|
|
20
|
+
release = resolve;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
await previous;
|
|
24
|
+
try {
|
|
25
|
+
return await fn();
|
|
26
|
+
} finally {
|
|
27
|
+
release();
|
|
28
|
+
entry.holders -= 1;
|
|
29
|
+
if (entry.holders === 0) locks.delete(name);
|
|
30
|
+
}
|
|
31
|
+
}
|
package/templates/kj.config.yml
CHANGED
|
@@ -114,5 +114,7 @@ output:
|
|
|
114
114
|
session:
|
|
115
115
|
max_iteration_minutes: 15
|
|
116
116
|
max_total_minutes: 120
|
|
117
|
-
|
|
117
|
+
# Hard per-run spend ceiling (USD-equivalent). The run stops with a summary
|
|
118
|
+
# when exceeded; resume with `kj resume`. Set to null to remove the cap.
|
|
119
|
+
max_budget_usd: 5
|
|
118
120
|
fail_fast_repeats: 2
|