karajan-code 2.17.1 → 2.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +1 -1
- package/src/brain/agent-error-classifier.js +5 -1
- package/src/brain/standby-store.js +38 -1
- package/src/brain/with-brain-recovery.js +65 -8
- package/src/commands/plan/generate.js +22 -0
- package/src/commands/resume.js +48 -27
- package/src/commands/run.js +5 -0
- package/src/config/loader.js +27 -3
- package/src/hu/auto-generator.js +170 -39
- package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +21 -0
- package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +22 -2
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +56 -0
- package/src/orchestrator/flow-runner.js +5 -0
- package/src/orchestrator/stages/coder-stage.js +47 -4
- package/src/orchestrator/verification-gate.js +38 -9
- package/src/plan/plan-store.js +53 -9
- package/src/plan/tests-synthesizer.js +16 -6
- package/src/prompts/coder.js +27 -4
- package/src/roles/agent-role.js +24 -1
- package/src/roles/coder-role.js +14 -1
- package/src/roles/triage-role.js +28 -9
- package/src/session/store.js +2 -1
- package/src/utils/atomic-write.js +43 -0
- package/src/utils/display/resume-hint.js +41 -0
- package/src/utils/process.js +18 -0
- package/src/utils/rate-limit-detector.js +22 -0
- package/src/utils/run-registry.js +2 -1
package/src/hu/auto-generator.js
CHANGED
|
@@ -66,64 +66,191 @@ export function needsSetupHu({ isNewProject = false, stackHints = [], subtasks =
|
|
|
66
66
|
return subtasks.some(s => setupKeywords.test(s));
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Stack-hint keyword sets. Used by filterConflictingHints (drop hints
|
|
70
|
+
// from other ecosystems once one is detected) and by
|
|
71
|
+
// resolveLanguageFromHints (pick the canonical language for setup +
|
|
72
|
+
// acceptance_tests templates).
|
|
73
|
+
const HINT_NODE = new Set(["express", "vite", "vitest", "jest", "next", "astro", "react", "vue", "svelte", "nestjs", "monorepo", "workspaces"]);
|
|
74
|
+
const HINT_PYTHON = new Set(["pytest", "flask", "fastapi", "django", "numpy", "pandas"]);
|
|
75
|
+
const HINT_GO = new Set(["gin", "fiber", "go"]);
|
|
76
|
+
const HINT_RUST = new Set(["cargo", "rust"]);
|
|
77
|
+
|
|
69
78
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
79
|
+
* Drop hints from OTHER ecosystems when one is clearly the project's.
|
|
80
|
+
* Symmetric: previously only node-vs-go was filtered, so a Python
|
|
81
|
+
* project that also had "vitest" in a stale gitignore would still
|
|
82
|
+
* receive vitest in its setup HU.
|
|
73
83
|
*/
|
|
74
84
|
function filterConflictingHints(hints) {
|
|
75
85
|
if (!hints || hints.length === 0) return hints;
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
86
|
+
const setForEachLang = [
|
|
87
|
+
["python", HINT_PYTHON],
|
|
88
|
+
["go", HINT_GO],
|
|
89
|
+
["rust", HINT_RUST],
|
|
90
|
+
["node", HINT_NODE],
|
|
91
|
+
];
|
|
92
|
+
const present = setForEachLang.filter(([, set]) => hints.some((h) => set.has(h)));
|
|
93
|
+
if (present.length <= 1) return hints;
|
|
94
|
+
// First ecosystem found in the original hints order wins. We keep
|
|
95
|
+
// ambiguous keywords (those not in any set) and the winner's own
|
|
96
|
+
// keywords; we drop the explicit losers from competing ecosystems.
|
|
97
|
+
const winnerSet = present[0][1];
|
|
98
|
+
const losers = present.slice(1).flatMap(([, s]) => [...s]);
|
|
99
|
+
return hints.filter((h) => winnerSet.has(h) || !losers.includes(h));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pick the canonical language for setup + acceptance_test templates
|
|
104
|
+
* based on the (already filtered) stack hints. Falls back to
|
|
105
|
+
* javascript so existing Node-centric runs keep their previous shape.
|
|
106
|
+
* @param {string[]} hints
|
|
107
|
+
* @returns {"python"|"go"|"rust"|"javascript"}
|
|
108
|
+
*/
|
|
109
|
+
export function resolveLanguageFromHints(hints) {
|
|
110
|
+
if (!hints || hints.length === 0) return "javascript";
|
|
111
|
+
if (hints.some((h) => HINT_PYTHON.has(h))) return "python";
|
|
112
|
+
if (hints.some((h) => HINT_GO.has(h))) return "go";
|
|
113
|
+
if (hints.some((h) => HINT_RUST.has(h))) return "rust";
|
|
114
|
+
return "javascript";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// HU templates per language. The JS template is the legacy default;
|
|
118
|
+
// the others were missing — every autogenerated HU got vitest /
|
|
119
|
+
// npm-install hardcoded acceptance_tests regardless of stack.
|
|
120
|
+
const HU_TEMPLATES = {
|
|
121
|
+
javascript: {
|
|
122
|
+
pkgManager: "npm",
|
|
123
|
+
setupBullets: [
|
|
124
|
+
"- Create package.json (with workspaces if monorepo detected from stack hints)",
|
|
125
|
+
"- Install all runtime + dev dependencies listed in stack hints",
|
|
126
|
+
"- Install test framework WITH coverage reporter (e.g. vitest + @vitest/coverage-v8)",
|
|
127
|
+
"- Configure vitest.config.js with coverage.enabled = true",
|
|
128
|
+
"- Create .env.example with placeholder variables",
|
|
129
|
+
],
|
|
130
|
+
setupCriteria: [
|
|
131
|
+
"npm install succeeds without errors",
|
|
132
|
+
"npm test runs without error",
|
|
133
|
+
"npm run test:coverage runs without error",
|
|
134
|
+
".env.example exists",
|
|
135
|
+
],
|
|
136
|
+
setupTests: [
|
|
137
|
+
"npm install --ignore-scripts 2>&1 && echo PASS || echo FAIL",
|
|
138
|
+
"npx vitest run 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
139
|
+
"npx vitest run --coverage 2>&1 | grep -q 'All files\\|% Stmts' && echo PASS || echo FAIL",
|
|
140
|
+
"test -f .env.example && echo PASS || echo FAIL",
|
|
141
|
+
],
|
|
142
|
+
taskTests: [
|
|
143
|
+
"npx vitest run 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
144
|
+
"npx vitest run --coverage 2>&1 | grep -q 'All files\\|% Stmts' && echo PASS || echo FAIL",
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
python: {
|
|
148
|
+
pkgManager: "pip",
|
|
149
|
+
setupBullets: [
|
|
150
|
+
"- Create pyproject.toml (preferred) or requirements.txt",
|
|
151
|
+
"- Create a virtual env: python -m venv .venv",
|
|
152
|
+
"- Install runtime + dev dependencies (pip install -e . or pip install -r requirements.txt)",
|
|
153
|
+
"- Install pytest with coverage (pip install pytest pytest-cov)",
|
|
154
|
+
"- Create .env.example with placeholder variables",
|
|
155
|
+
],
|
|
156
|
+
setupCriteria: [
|
|
157
|
+
"Project metadata file exists (pyproject.toml or requirements.txt)",
|
|
158
|
+
"pytest runs without error",
|
|
159
|
+
"pytest --cov reports a total",
|
|
160
|
+
".env.example exists",
|
|
161
|
+
],
|
|
162
|
+
setupTests: [
|
|
163
|
+
"(test -f pyproject.toml || test -f requirements.txt) && echo PASS || echo FAIL",
|
|
164
|
+
"python -m pytest 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
165
|
+
"python -m pytest --cov 2>&1 | grep -q 'TOTAL\\|coverage' && echo PASS || echo FAIL",
|
|
166
|
+
"test -f .env.example && echo PASS || echo FAIL",
|
|
167
|
+
],
|
|
168
|
+
taskTests: [
|
|
169
|
+
"python -m pytest 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
170
|
+
"python -m pytest --cov 2>&1 | grep -q 'TOTAL\\|coverage' && echo PASS || echo FAIL",
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
go: {
|
|
174
|
+
pkgManager: "go",
|
|
175
|
+
setupBullets: [
|
|
176
|
+
"- Create go.mod with `go mod init <module>`",
|
|
177
|
+
"- Install dependencies with `go get` per import",
|
|
178
|
+
"- Create .env.example with placeholder variables",
|
|
179
|
+
],
|
|
180
|
+
setupCriteria: [
|
|
181
|
+
"go.mod exists",
|
|
182
|
+
"go test ./... runs without error",
|
|
183
|
+
"go test -cover reports coverage",
|
|
184
|
+
".env.example exists",
|
|
185
|
+
],
|
|
186
|
+
setupTests: [
|
|
187
|
+
"test -f go.mod && echo PASS || echo FAIL",
|
|
188
|
+
"go test ./... 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
189
|
+
"go test -cover ./... 2>&1 | grep -q 'coverage:' && echo PASS || echo FAIL",
|
|
190
|
+
"test -f .env.example && echo PASS || echo FAIL",
|
|
191
|
+
],
|
|
192
|
+
taskTests: [
|
|
193
|
+
"go test ./... 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
rust: {
|
|
197
|
+
pkgManager: "cargo",
|
|
198
|
+
setupBullets: [
|
|
199
|
+
"- Initialise the crate with `cargo init`",
|
|
200
|
+
"- Add dependencies with `cargo add`",
|
|
201
|
+
"- Create .env.example with placeholder variables",
|
|
202
|
+
],
|
|
203
|
+
setupCriteria: [
|
|
204
|
+
"Cargo.toml exists",
|
|
205
|
+
"cargo test runs without error",
|
|
206
|
+
".env.example exists",
|
|
207
|
+
],
|
|
208
|
+
setupTests: [
|
|
209
|
+
"test -f Cargo.toml && echo PASS || echo FAIL",
|
|
210
|
+
"cargo test 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
211
|
+
"test -f .env.example && echo PASS || echo FAIL",
|
|
212
|
+
],
|
|
213
|
+
taskTests: [
|
|
214
|
+
"cargo test 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
function templateFor(language) {
|
|
220
|
+
return HU_TEMPLATES[language] || HU_TEMPLATES.javascript;
|
|
81
221
|
}
|
|
82
222
|
|
|
83
223
|
/**
|
|
84
224
|
* Build a MINIMAL setup HU — project structure + deps only.
|
|
85
225
|
* NEVER includes the full original task. The coder must only do setup.
|
|
86
226
|
*/
|
|
87
|
-
function buildSetupHu({ stackHints }) {
|
|
227
|
+
function buildSetupHu({ stackHints, language = "javascript" }) {
|
|
228
|
+
const tpl = templateFor(language);
|
|
88
229
|
const deps = stackHints.length > 0
|
|
89
230
|
? stackHints.map(h => `- ${h}`).join("\n")
|
|
90
231
|
: "- (auto-detect from subsequent HUs)";
|
|
91
232
|
const certifiedText = [
|
|
92
|
-
|
|
233
|
+
`**Setup: initialise the ${language} project + install dependencies (package manager: ${tpl.pkgManager}).**`,
|
|
93
234
|
"",
|
|
94
235
|
"SCOPE (do ONLY this, nothing else):",
|
|
95
|
-
|
|
96
|
-
"- Install all runtime + dev dependencies listed in stack hints",
|
|
97
|
-
"- Install test framework WITH coverage reporter (e.g. vitest + @vitest/coverage-v8)",
|
|
98
|
-
"- Configure vitest.config.js with coverage.enabled = true",
|
|
99
|
-
"- Create .env.example with placeholder variables",
|
|
236
|
+
...tpl.setupBullets,
|
|
100
237
|
"- Verify by running each acceptance_test command below",
|
|
101
238
|
"",
|
|
102
239
|
"DO NOT implement any business logic, API routes, components, or features.",
|
|
103
240
|
"This HU is ONLY project scaffolding.",
|
|
104
241
|
"",
|
|
105
242
|
"Stack hints:",
|
|
106
|
-
deps
|
|
243
|
+
deps,
|
|
107
244
|
].join("\n");
|
|
108
245
|
return {
|
|
109
246
|
id: "HU-01",
|
|
110
|
-
title:
|
|
247
|
+
title: `Setup: ${language} project structure + dependencies`,
|
|
111
248
|
task_type: "infra",
|
|
112
249
|
status: "certified",
|
|
113
250
|
blocked_by: [],
|
|
114
251
|
certified: { text: certifiedText },
|
|
115
|
-
acceptance_criteria:
|
|
116
|
-
|
|
117
|
-
"npm test runs without error",
|
|
118
|
-
"npm run test:coverage runs without error",
|
|
119
|
-
".env.example exists"
|
|
120
|
-
],
|
|
121
|
-
acceptance_tests: [
|
|
122
|
-
"npm install --ignore-scripts 2>&1 && echo PASS || echo FAIL",
|
|
123
|
-
"npx vitest run 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
124
|
-
"npx vitest run --coverage 2>&1 | grep -q 'All files\\|% Stmts' && echo PASS || echo FAIL",
|
|
125
|
-
"test -f .env.example && echo PASS || echo FAIL"
|
|
126
|
-
]
|
|
252
|
+
acceptance_criteria: tpl.setupCriteria,
|
|
253
|
+
acceptance_tests: tpl.setupTests,
|
|
127
254
|
};
|
|
128
255
|
}
|
|
129
256
|
|
|
@@ -131,8 +258,9 @@ function buildSetupHu({ stackHints }) {
|
|
|
131
258
|
* Build a MINIMAL task HU — one specific, focused piece of work.
|
|
132
259
|
* Includes a short goal reference (max 80 chars) NOT the full task.
|
|
133
260
|
*/
|
|
134
|
-
function buildTaskHu({ id, subtask, projectName, blockedBy }) {
|
|
261
|
+
function buildTaskHu({ id, subtask, projectName, blockedBy, language = "javascript" }) {
|
|
135
262
|
const taskType = classifyTaskType(subtask);
|
|
263
|
+
const tpl = templateFor(language);
|
|
136
264
|
const certifiedText = [
|
|
137
265
|
`**${subtask}**`,
|
|
138
266
|
"",
|
|
@@ -143,7 +271,7 @@ function buildTaskHu({ id, subtask, projectName, blockedBy }) {
|
|
|
143
271
|
"- Add unit tests for the new code",
|
|
144
272
|
"- Run ALL acceptance_tests listed below and ensure they pass",
|
|
145
273
|
"- Do NOT touch code outside this subtask's scope",
|
|
146
|
-
"- Target: <200 lines changed (like an atomic PR)"
|
|
274
|
+
"- Target: <200 lines changed (like an atomic PR)",
|
|
147
275
|
].join("\n");
|
|
148
276
|
return {
|
|
149
277
|
id,
|
|
@@ -155,12 +283,9 @@ function buildTaskHu({ id, subtask, projectName, blockedBy }) {
|
|
|
155
283
|
acceptance_criteria: [
|
|
156
284
|
`${subtask} is implemented and working`,
|
|
157
285
|
"Unit tests cover the new code",
|
|
158
|
-
"All acceptance_tests pass"
|
|
286
|
+
"All acceptance_tests pass",
|
|
159
287
|
],
|
|
160
|
-
acceptance_tests:
|
|
161
|
-
"npx vitest run 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
162
|
-
"npx vitest run --coverage 2>&1 | grep -q 'All files\\|% Stmts' && echo PASS || echo FAIL"
|
|
163
|
-
]
|
|
288
|
+
acceptance_tests: tpl.taskTests,
|
|
164
289
|
};
|
|
165
290
|
}
|
|
166
291
|
|
|
@@ -182,7 +307,8 @@ export function generateHuBatch({
|
|
|
182
307
|
stackHints = [],
|
|
183
308
|
isNewProject = false,
|
|
184
309
|
researcherContext = null,
|
|
185
|
-
architectContext = null
|
|
310
|
+
architectContext = null,
|
|
311
|
+
language = null,
|
|
186
312
|
}) {
|
|
187
313
|
if (!originalTask || typeof originalTask !== "string") {
|
|
188
314
|
throw new Error("generateHuBatch: originalTask is required");
|
|
@@ -194,12 +320,17 @@ export function generateHuBatch({
|
|
|
194
320
|
const stories = [];
|
|
195
321
|
const filteredHints = filterConflictingHints(stackHints);
|
|
196
322
|
const needsSetup = needsSetupHu({ isNewProject, stackHints: filteredHints, subtasks });
|
|
323
|
+
// Caller may pass a language explicitly (when it has a real
|
|
324
|
+
// detectProjectStack result from the filesystem); otherwise fall
|
|
325
|
+
// back to inferring from the hint keywords. Default 'javascript'
|
|
326
|
+
// matches the previous behaviour for Node-centric runs.
|
|
327
|
+
const resolvedLanguage = language || resolveLanguageFromHints(filteredHints);
|
|
197
328
|
let nextId = 1;
|
|
198
329
|
|
|
199
330
|
const projectName = deriveProjectName(originalTask);
|
|
200
331
|
|
|
201
332
|
if (needsSetup) {
|
|
202
|
-
stories.push(buildSetupHu({ stackHints: filteredHints }));
|
|
333
|
+
stories.push(buildSetupHu({ stackHints: filteredHints, language: resolvedLanguage }));
|
|
203
334
|
nextId = 2;
|
|
204
335
|
}
|
|
205
336
|
|
|
@@ -211,7 +342,7 @@ export function generateHuBatch({
|
|
|
211
342
|
const blockedBy = [];
|
|
212
343
|
if (setupId) blockedBy.push(setupId);
|
|
213
344
|
if (previousId && previousId !== setupId) blockedBy.push(previousId);
|
|
214
|
-
stories.push(buildTaskHu({ id, subtask, projectName, blockedBy }));
|
|
345
|
+
stories.push(buildTaskHu({ id, subtask, projectName, blockedBy, language: resolvedLanguage }));
|
|
215
346
|
previousId = id;
|
|
216
347
|
nextId += 1;
|
|
217
348
|
}
|
|
@@ -18,6 +18,25 @@ import { stageRegistry } from "../../stages/stage-classes.js";
|
|
|
18
18
|
import { runStage } from "../../stages/stage-executor.js";
|
|
19
19
|
import { handleStandbyResult } from "../error-recovery.js";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A coder/refactorer stage hit a provider quota cap and Brain Recovery
|
|
23
|
+
* hibernated the run. Turn it into a clean loop return carrying
|
|
24
|
+
* `hibernated:true` — the iteration loop and flow-runner read that to
|
|
25
|
+
* seal the session as `hibernated` (NOT `failed`) so it stays resumable.
|
|
26
|
+
*/
|
|
27
|
+
function hibernateReturn(stageResult, session) {
|
|
28
|
+
return {
|
|
29
|
+
action: "return",
|
|
30
|
+
result: {
|
|
31
|
+
hibernated: true,
|
|
32
|
+
sessionId: session?.id || null,
|
|
33
|
+
reason: "quota_exhausted",
|
|
34
|
+
standbyFile: stageResult.standbyFile || null,
|
|
35
|
+
recovery: stageResult.recovery || null,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
21
40
|
export async function runCoderAndRefactorerStages({ coderRoleInstance, coderRole, refactorerRole, pipelineFlags, config, logger, emitter, eventBase, session, plannedTask, trackBudget, i, brainCtx }) {
|
|
22
41
|
// Coder via StageRegistry (TSK-0336). canRun = coderRequired !== false; in
|
|
23
42
|
// analysis-only task types coderRequired is set to false by policy, so the
|
|
@@ -25,6 +44,7 @@ export async function runCoderAndRefactorerStages({ coderRoleInstance, coderRole
|
|
|
25
44
|
// runFlow.
|
|
26
45
|
const coderCtx = { coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration: i, brainCtx, pipelineFlags };
|
|
27
46
|
const coderResult = await runStage(stageRegistry.get("coder"), coderCtx);
|
|
47
|
+
if (coderResult?.action === "hibernate") return hibernateReturn(coderResult, session);
|
|
28
48
|
if (coderResult?.action === "pause") return { action: "return", result: coderResult.result };
|
|
29
49
|
const coderStandby = await handleStandbyResult({ stageResult: coderResult, session, emitter, eventBase, i, stage: "coder", logger, config });
|
|
30
50
|
if (coderStandby.handled) {
|
|
@@ -35,6 +55,7 @@ export async function runCoderAndRefactorerStages({ coderRoleInstance, coderRole
|
|
|
35
55
|
|
|
36
56
|
if (pipelineFlags.refactorerEnabled) {
|
|
37
57
|
const refResult = await runRefactorerStage({ refactorerRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration: i });
|
|
58
|
+
if (refResult?.action === "hibernate") return hibernateReturn(refResult, session);
|
|
38
59
|
if (refResult?.action === "pause") return { action: "return", result: refResult.result };
|
|
39
60
|
const refStandby = await handleStandbyResult({ stageResult: refResult, session, emitter, eventBase, i, stage: "refactorer", logger, config });
|
|
40
61
|
if (refStandby.handled) {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { emitProgress, makeEvent } from "#utils/events.js";
|
|
19
|
+
import { detectProjectStack } from "#utils/stack-detect.js";
|
|
19
20
|
|
|
20
21
|
export async function maybeGenerateAutoHuBatch({
|
|
21
22
|
flags, stageResults, task, logger, emitter, eventBase, projectDir, session,
|
|
@@ -40,19 +41,38 @@ export async function maybeGenerateAutoHuBatch({
|
|
|
40
41
|
isNewProject = relevant.length === 0;
|
|
41
42
|
} catch { /* ignore */ }
|
|
42
43
|
|
|
43
|
-
//
|
|
44
|
+
// Stack hints from planner + architect text. Now multi-ecosystem
|
|
45
|
+
// (was 13/16 JS keywords — Python/Go/Rust barely existed). Combined
|
|
46
|
+
// with `detectProjectStack(projectDir)` below to override the text
|
|
47
|
+
// heuristic with the actual filesystem reality when available.
|
|
44
48
|
const stackHints = [];
|
|
45
49
|
const combined = `${stageResults.planner?.plan || ""} ${stageResults.architect?.architecture ? JSON.stringify(stageResults.architect.architecture) : ""} ${task}`.toLowerCase();
|
|
46
|
-
const stackKeywords = [
|
|
50
|
+
const stackKeywords = [
|
|
51
|
+
"express", "vite", "vitest", "jest", "next", "astro", "react", "vue", "svelte", "nestjs", "monorepo", "workspaces",
|
|
52
|
+
"pytest", "flask", "fastapi", "django", "numpy", "pandas",
|
|
53
|
+
"gin", "fiber", "go",
|
|
54
|
+
"cargo", "rust",
|
|
55
|
+
"spring",
|
|
56
|
+
];
|
|
47
57
|
for (const kw of stackKeywords) {
|
|
48
58
|
if (combined.includes(kw)) stackHints.push(kw);
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
// Filesystem trumps text. detectProjectStack inspects package.json /
|
|
62
|
+
// pyproject.toml / go.mod / Cargo.toml, so a pure-python repo gets
|
|
63
|
+
// `language: "python"` regardless of what the task text says.
|
|
64
|
+
let detectedLanguage = null;
|
|
65
|
+
try {
|
|
66
|
+
const fsStack = await detectProjectStack(projectDir);
|
|
67
|
+
if (fsStack?.language) detectedLanguage = fsStack.language;
|
|
68
|
+
} catch { /* best-effort */ }
|
|
69
|
+
|
|
51
70
|
const batch = generateHuBatch({
|
|
52
71
|
originalTask: task,
|
|
53
72
|
subtasks,
|
|
54
73
|
stackHints,
|
|
55
74
|
isNewProject,
|
|
75
|
+
language: detectedLanguage,
|
|
56
76
|
researcherContext: stageResults.researcher?.summary || null,
|
|
57
77
|
architectContext: stageResults.architect?.architecture ? JSON.stringify(stageResults.architect.architecture) : null
|
|
58
78
|
});
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
setLiveOutcomeUpdater, setPreLoopContext,
|
|
33
33
|
} from "#session/mutators.js";
|
|
34
34
|
import { emitProgress, makeEvent } from "#utils/events.js";
|
|
35
|
+
import { listActiveRuns } from "../../../utils/run-registry.js";
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* @param {object} args
|
|
@@ -64,6 +65,19 @@ export async function injectLoadedPlan({ flags, updatedConfig, session, stageRes
|
|
|
64
65
|
message: `Plan loaded: ${flags.plan}`,
|
|
65
66
|
detail: { planId: flags.plan, task: loadedPlan.task, version: loadedPlan.version, huCount: loadedPlan.hus?.length || 0 }
|
|
66
67
|
}));
|
|
68
|
+
|
|
69
|
+
// Zombie reconciliation: a previous run may have died mid-flight,
|
|
70
|
+
// leaving HUs stuck in coding/reviewing/running. The board-side
|
|
71
|
+
// reaper only runs inside the board; without this, a headless
|
|
72
|
+
// `kj run --plan` would refuse to pick those HUs up.
|
|
73
|
+
const reaped = await reconcilePlanHuZombies({ loadedPlan, planId: flags.plan, logger });
|
|
74
|
+
if (reaped > 0) {
|
|
75
|
+
await savePlanToDisk(projectDir, loadedPlan);
|
|
76
|
+
emitProgress(emitter, makeEvent("plan:zombies-reaped", { ...eventBase, stage: "plan" }, {
|
|
77
|
+
message: `Reset ${reaped} stale HU(s) from a previous run that did not finish`,
|
|
78
|
+
detail: { planId: flags.plan, count: reaped }
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
67
81
|
stageResults.researcher = { ok: true, summary: "Loaded from persisted plan", fromPlan: flags.plan };
|
|
68
82
|
stageResults.architect = { ok: true, summary: "Loaded from persisted plan", fromPlan: flags.plan };
|
|
69
83
|
stageResults.planner = { ok: true, summary: "Loaded from persisted plan", fromPlan: flags.plan };
|
|
@@ -122,6 +136,48 @@ export async function injectLoadedPlan({ flags, updatedConfig, session, stageRes
|
|
|
122
136
|
}
|
|
123
137
|
}
|
|
124
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Reset HUs that look mid-execution but have no live run behind them.
|
|
141
|
+
*
|
|
142
|
+
* Resilience audit, Phase 3 (Hallazgo A1 / status-zombi). A previous
|
|
143
|
+
* `kj run --plan` flipped HUs to `coding`/`reviewing`/`running` in the
|
|
144
|
+
* plan JSON, then died (SIGKILL, terminal closed, OOM, crash). The
|
|
145
|
+
* board-side `hu-zombie-reaper` only queries SQLite and only runs
|
|
146
|
+
* inside the board process, so the plan JSON on disk kept stale
|
|
147
|
+
* in-flight statuses forever. The next `kj run --plan` then refused to
|
|
148
|
+
* pick those HUs up.
|
|
149
|
+
*
|
|
150
|
+
* Cross-check via `run-registry`: if there is a live run with our
|
|
151
|
+
* planId, do NOT touch — that run owns the in-flight HUs. Otherwise
|
|
152
|
+
* any in-flight status with no live owner is a zombie → reset to
|
|
153
|
+
* `pending` so it can run again.
|
|
154
|
+
*
|
|
155
|
+
* Mutates `loadedPlan.hus` in place. Returns the number of HUs reset
|
|
156
|
+
* so the caller can persist + emit a progress event.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} args
|
|
159
|
+
* @param {object} args.loadedPlan
|
|
160
|
+
* @param {string} args.planId
|
|
161
|
+
* @param {object} [args.logger]
|
|
162
|
+
* @returns {number}
|
|
163
|
+
*/
|
|
164
|
+
export function reconcilePlanHuZombies({ loadedPlan, planId, logger }) {
|
|
165
|
+
if (!Array.isArray(loadedPlan?.hus) || loadedPlan.hus.length === 0) return 0;
|
|
166
|
+
const activeRuns = listActiveRuns({ planId });
|
|
167
|
+
if (activeRuns.length > 0) return 0;
|
|
168
|
+
const ZOMBIE_STATES = new Set(["coding", "reviewing", "running"]);
|
|
169
|
+
let reaped = 0;
|
|
170
|
+
for (const hu of loadedPlan.hus) {
|
|
171
|
+
if (ZOMBIE_STATES.has(hu.status)) {
|
|
172
|
+
const previous = hu.status;
|
|
173
|
+
hu.status = "pending";
|
|
174
|
+
reaped += 1;
|
|
175
|
+
logger?.warn?.(`[plan] HU ${hu.id || hu.hu_id || "?"} was '${previous}' but no live run owns the plan — reset to 'pending'.`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return reaped;
|
|
179
|
+
}
|
|
180
|
+
|
|
125
181
|
/**
|
|
126
182
|
* Throws when any HU lacks an acceptance_tests array with at least
|
|
127
183
|
* one entry. Logs + emits the same `plan:tests-missing` progress
|
|
@@ -74,6 +74,7 @@ import { runSecurityStage, runFinalAuditStage } from "./post-loop-stages.js";
|
|
|
74
74
|
* approved → "approved"
|
|
75
75
|
* paused → "paused"
|
|
76
76
|
* cancelled → "cancelled"
|
|
77
|
+
* hibernated → "hibernated"
|
|
77
78
|
* anything else → "failed"
|
|
78
79
|
*
|
|
79
80
|
* Idempotent: only writes when the current status is still "running"
|
|
@@ -95,6 +96,10 @@ export async function sealSessionStatusIfStillRunning(session, result) {
|
|
|
95
96
|
if (result?.approved === true) target = "approved";
|
|
96
97
|
else if (result?.paused === true) target = "paused";
|
|
97
98
|
else if (result?.cancelled === true) target = "cancelled";
|
|
99
|
+
// A run hibernated on a provider quota cap is NOT a failure — it is
|
|
100
|
+
// suspended and resumable via `kj standby resume`. Sealing it as
|
|
101
|
+
// `failed` would let the HU-zombie reaper treat it as dead work.
|
|
102
|
+
else if (result?.hibernated === true) target = "hibernated";
|
|
98
103
|
await markSessionStatus(session, target);
|
|
99
104
|
} catch { /* non-blocking */ }
|
|
100
105
|
}
|
|
@@ -18,6 +18,7 @@ import { runCoderWithFallback } from "../agent-fallback.js";
|
|
|
18
18
|
import { invokeSolomon } from "../solomon-escalation.js";
|
|
19
19
|
import { detectRateLimit } from "../../utils/rate-limit-detector.js";
|
|
20
20
|
import { createStallDetector } from "../../utils/stall-detector.js";
|
|
21
|
+
import { buildStandbyState } from "../../brain/standby-store.js";
|
|
21
22
|
import { snapshotHomeTopLevel, detectNewHomeEntries, formatLeakMessage, verifyLeaksAgainstTranscript, detectTranscriptCdLeaks } from "../fs-leak-detector.js";
|
|
22
23
|
|
|
23
24
|
export async function runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration, brainCtx, acceptanceTests = null, adrs = null, specSection = null, reviewerFindings = null, huId = null }) {
|
|
@@ -66,7 +67,10 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
|
|
|
66
67
|
// the plan, the rest are scoped to this HU. All four pass through
|
|
67
68
|
// untouched when the caller omits them (legacy single-task runs).
|
|
68
69
|
adrs, specSection, reviewerFindings, huId,
|
|
69
|
-
onOutput: coderStall.onOutput
|
|
70
|
+
onOutput: coderStall.onOutput,
|
|
71
|
+
// Lets Brain Recovery persist a standby snapshot if the coder's
|
|
72
|
+
// provider hits a quota cap mid-run (KJC hibernation wiring).
|
|
73
|
+
sessionState: buildStandbyState({ session, config, huId }),
|
|
70
74
|
});
|
|
71
75
|
} finally {
|
|
72
76
|
coderStall.stop();
|
|
@@ -75,6 +79,21 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
|
|
|
75
79
|
|
|
76
80
|
if (!coderExecResult.ok) {
|
|
77
81
|
const details = coderExecResult.result?.error || coderExecResult.summary || "unknown error";
|
|
82
|
+
// Quota cap → Brain Recovery already hibernated the run and persisted
|
|
83
|
+
// the standby snapshot. Stop the stage cleanly: no fallback, no
|
|
84
|
+
// Solomon. The caller turns this into a clean run halt; the HU is NOT
|
|
85
|
+
// marked failed — it resumes via `kj standby resume`.
|
|
86
|
+
if (coderExecResult.result?.action === "hibernate") {
|
|
87
|
+
emitProgress(emitter, makeEvent("coder:hibernate", { ...eventBase, stage: "coder" }, {
|
|
88
|
+
message: "Coder hibernated — provider quota exhausted, run can be resumed",
|
|
89
|
+
detail: { standbyFile: coderExecResult.result?.standbyFile || null }
|
|
90
|
+
}));
|
|
91
|
+
return {
|
|
92
|
+
action: "hibernate",
|
|
93
|
+
standbyFile: coderExecResult.result?.standbyFile || null,
|
|
94
|
+
recovery: coderExecResult.result?.recovery || null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
78
97
|
const rateLimitCheck = detectRateLimit({
|
|
79
98
|
stderr: coderExecResult.result?.error || "",
|
|
80
99
|
stdout: coderExecResult.result?.output || ""
|
|
@@ -190,8 +209,17 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
|
|
|
190
209
|
baseRef: session.session_start_sha,
|
|
191
210
|
projectDir: config.projectDir || process.cwd()
|
|
192
211
|
});
|
|
193
|
-
|
|
194
|
-
|
|
212
|
+
if (verif.gitError) {
|
|
213
|
+
// Git failed (bad baseRef, corrupt repo, git missing). Don't
|
|
214
|
+
// feed filesChanged=0 into stale detection — it would blame the
|
|
215
|
+
// coder for an infrastructure problem.
|
|
216
|
+
logger?.warn?.(`[verification-gate] Git failed, change count is unreliable: ${verif.gitError}`);
|
|
217
|
+
} else {
|
|
218
|
+
filesChanged = verif.filesChanged || 0;
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
logger?.warn?.(`[verification-gate] Skipped: ${err?.message || err}`);
|
|
222
|
+
}
|
|
195
223
|
|
|
196
224
|
await addCheckpoint(session, { stage: "coder", iteration, note: "Coder applied changes", provider: coderRole.provider, model: coderRole.model || null, filesChanged });
|
|
197
225
|
emitProgress(
|
|
@@ -245,13 +273,28 @@ export async function runRefactorerStage({ refactorerRole, config, logger, emitt
|
|
|
245
273
|
const refactorerStart = Date.now();
|
|
246
274
|
let refResult;
|
|
247
275
|
try {
|
|
248
|
-
refResult = await refRole.execute({
|
|
276
|
+
refResult = await refRole.execute({
|
|
277
|
+
task: plannedTask, onOutput: refactorerStall.onOutput,
|
|
278
|
+
sessionState: buildStandbyState({ session, config }),
|
|
279
|
+
});
|
|
249
280
|
} finally {
|
|
250
281
|
refactorerStall.stop();
|
|
251
282
|
}
|
|
252
283
|
trackBudget({ role: "refactorer", provider: refactorerRole.provider, model: refactorerRole.model, result: refResult.result, duration_ms: Date.now() - refactorerStart });
|
|
253
284
|
if (!refResult.ok) {
|
|
254
285
|
const details = refResult.result?.error || refResult.summary || "unknown error";
|
|
286
|
+
// Quota cap → hibernate cleanly, same as the coder stage above.
|
|
287
|
+
if (refResult.result?.action === "hibernate") {
|
|
288
|
+
emitProgress(emitter, makeEvent("refactorer:hibernate", { ...eventBase, stage: "refactorer" }, {
|
|
289
|
+
message: "Refactorer hibernated — provider quota exhausted, run can be resumed",
|
|
290
|
+
detail: { standbyFile: refResult.result?.standbyFile || null }
|
|
291
|
+
}));
|
|
292
|
+
return {
|
|
293
|
+
action: "hibernate",
|
|
294
|
+
standbyFile: refResult.result?.standbyFile || null,
|
|
295
|
+
recovery: refResult.result?.recovery || null,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
255
298
|
const rateLimitCheck = detectRateLimit({
|
|
256
299
|
stderr: refResult.result?.error || "",
|
|
257
300
|
stdout: refResult.result?.output || ""
|
|
@@ -47,8 +47,16 @@ export function countChangesSince(baseRef, projectDir = null) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
return { files, filesChanged: files.length, linesAdded, linesDeleted };
|
|
50
|
-
} catch {
|
|
51
|
-
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// Git itself failed (baseRef invalid, repo corrupt, git binary
|
|
52
|
+
// missing). Previously we returned `filesChanged: 0` silently —
|
|
53
|
+
// indistinguishable from "the coder did nothing". Surface the
|
|
54
|
+
// error so verifyCoderOutput stops blaming the agent for an
|
|
55
|
+
// infrastructure failure.
|
|
56
|
+
return {
|
|
57
|
+
files: [], filesChanged: 0, linesAdded: 0, linesDeleted: 0,
|
|
58
|
+
gitError: err?.stderr?.toString?.() || err?.message || String(err),
|
|
59
|
+
};
|
|
52
60
|
}
|
|
53
61
|
}
|
|
54
62
|
|
|
@@ -65,10 +73,13 @@ export function countUntrackedFiles(projectDir = null) {
|
|
|
65
73
|
encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
|
|
66
74
|
}).trim();
|
|
67
75
|
|
|
68
|
-
if (!output) return [];
|
|
69
|
-
return output.split("\n").filter(Boolean);
|
|
70
|
-
} catch {
|
|
71
|
-
return
|
|
76
|
+
if (!output) return { files: [] };
|
|
77
|
+
return { files: output.split("\n").filter(Boolean) };
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return {
|
|
80
|
+
files: [],
|
|
81
|
+
gitError: err?.stderr?.toString?.() || err?.message || String(err),
|
|
82
|
+
};
|
|
72
83
|
}
|
|
73
84
|
}
|
|
74
85
|
|
|
@@ -87,11 +98,29 @@ export function verifyCoderOutput({ baseRef, projectDir = null, minFiles = 1, mi
|
|
|
87
98
|
const tracked = countChangesSince(baseRef, projectDir);
|
|
88
99
|
const untracked = countUntrackedFiles(projectDir);
|
|
89
100
|
|
|
90
|
-
|
|
101
|
+
// Git infra failure (bad baseRef, corrupt repo, git missing) — not
|
|
102
|
+
// the coder's fault. Bail out with `gitError` so the caller can log
|
|
103
|
+
// it and skip the "retry with explicit file paths" feedback that
|
|
104
|
+
// would otherwise blame the agent.
|
|
105
|
+
const gitError = tracked.gitError || untracked.gitError;
|
|
106
|
+
if (gitError) {
|
|
107
|
+
return {
|
|
108
|
+
passed: false,
|
|
109
|
+
filesChanged: 0,
|
|
110
|
+
linesChanged: 0,
|
|
111
|
+
files: [],
|
|
112
|
+
reason: `Git verification failed: ${gitError}`,
|
|
113
|
+
retryStrategy: null,
|
|
114
|
+
gitError,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const untrackedFiles = untracked.files || [];
|
|
119
|
+
const totalFiles = tracked.filesChanged + untrackedFiles.length;
|
|
91
120
|
const totalLines = tracked.linesAdded + tracked.linesDeleted;
|
|
92
|
-
const allFiles = [...tracked.files, ...
|
|
121
|
+
const allFiles = [...tracked.files, ...untrackedFiles];
|
|
93
122
|
|
|
94
|
-
const passed = totalFiles >= minFiles && (
|
|
123
|
+
const passed = totalFiles >= minFiles && (untrackedFiles.length > 0 || totalLines >= minLines);
|
|
95
124
|
|
|
96
125
|
if (passed) {
|
|
97
126
|
return {
|