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/plan/plan-store.js
CHANGED
|
@@ -7,7 +7,35 @@
|
|
|
7
7
|
import fs from "node:fs/promises";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import os from "node:os";
|
|
10
|
+
import { writeJsonAtomic } from "../utils/atomic-write.js";
|
|
10
11
|
import { generatePlanId, normaliseAlias } from "./plan-id.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A plan JSON file failed to parse — typically an interrupted write
|
|
15
|
+
* leaves it truncated. Previously every read site swallowed this with
|
|
16
|
+
* a silent catch and the plan vanished from `kj plan list` and
|
|
17
|
+
* `kj plan load` as if it had never existed.
|
|
18
|
+
*
|
|
19
|
+
* Now: warn loudly so the user knows their plan is corrupt, and rename
|
|
20
|
+
* the file to `<name>.corrupt-<ts>` so it stops blocking subsequent
|
|
21
|
+
* reads of the same alias / planId (without destroying evidence).
|
|
22
|
+
* Best-effort: if the rename itself fails (read-only FS, permissions),
|
|
23
|
+
* we log and move on — never escalate to a throw, that would block the
|
|
24
|
+
* caller from reading the rest of the directory.
|
|
25
|
+
*/
|
|
26
|
+
async function handleCorruptPlanFile(filePath, error) {
|
|
27
|
+
const corruptPath = `${filePath}.corrupt-${Date.now()}`;
|
|
28
|
+
// eslint-disable-next-line no-console
|
|
29
|
+
console.warn(`[plan-store] Corrupt plan JSON at ${filePath}: ${error.message}`);
|
|
30
|
+
try {
|
|
31
|
+
await fs.rename(filePath, corruptPath);
|
|
32
|
+
// eslint-disable-next-line no-console
|
|
33
|
+
console.warn(`[plan-store] Renamed to ${path.basename(corruptPath)} so it stops blocking subsequent reads.`);
|
|
34
|
+
} catch (renameErr) {
|
|
35
|
+
// eslint-disable-next-line no-console
|
|
36
|
+
console.warn(`[plan-store] Could not rename ${filePath}: ${renameErr.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
11
39
|
import { isPlanV2, migratePlanV1toV2 } from "./plan-schema.js";
|
|
12
40
|
|
|
13
41
|
// Per-process tmp dir for VITEST runs that forget to set KJ_HOME. We
|
|
@@ -83,7 +111,7 @@ export async function savePlan(projectDir, planResult) {
|
|
|
83
111
|
if (!planResult.alias && planResult.name) {
|
|
84
112
|
planResult.alias = await resolveUniqueAlias(dir, planResult.planId, normaliseAlias(planResult.name));
|
|
85
113
|
}
|
|
86
|
-
await
|
|
114
|
+
await writeJsonAtomic(filePath, planResult);
|
|
87
115
|
return planResult.planId;
|
|
88
116
|
}
|
|
89
117
|
|
|
@@ -100,7 +128,7 @@ export async function savePlan(projectDir, planResult) {
|
|
|
100
128
|
};
|
|
101
129
|
|
|
102
130
|
const filePath = path.join(dir, `${planId}.json`);
|
|
103
|
-
await
|
|
131
|
+
await writeJsonAtomic(filePath, record);
|
|
104
132
|
return planId;
|
|
105
133
|
}
|
|
106
134
|
|
|
@@ -119,11 +147,14 @@ async function resolveUniqueAlias(dir, ownPlanId, base) {
|
|
|
119
147
|
const taken = new Set();
|
|
120
148
|
for (const f of files) {
|
|
121
149
|
if (!f.endsWith(".json")) continue;
|
|
150
|
+
const filePath = path.join(dir, f);
|
|
122
151
|
try {
|
|
123
|
-
const record = JSON.parse(await fs.readFile(
|
|
152
|
+
const record = JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
124
153
|
if (record.planId === ownPlanId) continue;
|
|
125
154
|
if (record.alias) taken.add(record.alias);
|
|
126
|
-
} catch {
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (err.code !== "ENOENT") await handleCorruptPlanFile(filePath, err);
|
|
157
|
+
}
|
|
127
158
|
}
|
|
128
159
|
if (!taken.has(base)) return base;
|
|
129
160
|
for (let i = 2; i < 1000; i += 1) {
|
|
@@ -151,19 +182,28 @@ export async function loadPlan(projectDir, ref) {
|
|
|
151
182
|
const plan = JSON.parse(data);
|
|
152
183
|
if (!isPlanV2(plan)) return migratePlanV1toV2(plan);
|
|
153
184
|
return plan;
|
|
154
|
-
} catch {
|
|
185
|
+
} catch (err) {
|
|
186
|
+
// ENOENT is expected (caller may have passed an alias, not a planId)
|
|
187
|
+
// → fall through to the alias scan. Anything else means the file is
|
|
188
|
+
// there but unreadable / corrupt; surface it instead of silently
|
|
189
|
+
// hiding the plan.
|
|
190
|
+
if (err.code !== "ENOENT") await handleCorruptPlanFile(direct, err);
|
|
191
|
+
}
|
|
155
192
|
// 2. Escaneo del dir por alias. Coste O(N) pero N suele ser <10.
|
|
156
193
|
let files;
|
|
157
194
|
try { files = await fs.readdir(dir); } catch { return null; }
|
|
158
195
|
for (const f of files) {
|
|
159
196
|
if (!f.endsWith(".json")) continue;
|
|
197
|
+
const filePath = path.join(dir, f);
|
|
160
198
|
try {
|
|
161
|
-
const record = JSON.parse(await fs.readFile(
|
|
199
|
+
const record = JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
162
200
|
if (record.alias === ref || record.planId === ref) {
|
|
163
201
|
if (!isPlanV2(record)) return migratePlanV1toV2(record);
|
|
164
202
|
return record;
|
|
165
203
|
}
|
|
166
|
-
} catch {
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (err.code !== "ENOENT") await handleCorruptPlanFile(filePath, err);
|
|
206
|
+
}
|
|
167
207
|
}
|
|
168
208
|
return null;
|
|
169
209
|
}
|
|
@@ -197,8 +237,10 @@ export async function listPlans(projectDir) {
|
|
|
197
237
|
const plans = [];
|
|
198
238
|
for (const file of files) {
|
|
199
239
|
if (!file.endsWith(".json")) continue;
|
|
240
|
+
if (file.includes(".corrupt-")) continue; // own renamed-aside files
|
|
241
|
+
const filePath = path.join(dir, file);
|
|
200
242
|
try {
|
|
201
|
-
const data = await fs.readFile(
|
|
243
|
+
const data = await fs.readFile(filePath, "utf8");
|
|
202
244
|
const record = JSON.parse(data);
|
|
203
245
|
plans.push({
|
|
204
246
|
planId: record.planId,
|
|
@@ -210,7 +252,9 @@ export async function listPlans(projectDir) {
|
|
|
210
252
|
huCount: Array.isArray(record.hus) ? record.hus.length : 0,
|
|
211
253
|
createdAt: record.createdAt
|
|
212
254
|
});
|
|
213
|
-
} catch {
|
|
255
|
+
} catch (err) {
|
|
256
|
+
if (err.code !== "ENOENT") await handleCorruptPlanFile(filePath, err);
|
|
257
|
+
}
|
|
214
258
|
}
|
|
215
259
|
|
|
216
260
|
plans.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
|
|
@@ -33,7 +33,7 @@ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
|
|
|
33
33
|
* @param {Array<{ id: string, title?: string, scope?: string }>} args.husNeedingTests
|
|
34
34
|
* @returns {string}
|
|
35
35
|
*/
|
|
36
|
-
export function buildSynthesizerPrompt({ task, husNeedingTests }) {
|
|
36
|
+
export function buildSynthesizerPrompt({ task, husNeedingTests, stack = null, testFramework = null }) {
|
|
37
37
|
const lines = [
|
|
38
38
|
"You previously generated an implementation plan for the task below, but some HUs were left without acceptance_tests.",
|
|
39
39
|
"Your ONLY job in this call is to fill that gap.",
|
|
@@ -59,12 +59,22 @@ export function buildSynthesizerPrompt({ task, husNeedingTests }) {
|
|
|
59
59
|
"- Tests must be writable BEFORE the HU is implemented (they should fail until done).",
|
|
60
60
|
"- Return ONLY the JSON — no prose, no markdown fences around it.",
|
|
61
61
|
"",
|
|
62
|
-
"## Task",
|
|
63
|
-
task,
|
|
64
|
-
"",
|
|
65
|
-
"## HUs that need acceptance_tests",
|
|
66
|
-
"",
|
|
67
62
|
];
|
|
63
|
+
// Stack-aware hint: tell the LLM what test framework / language to
|
|
64
|
+
// emit shell commands for. Without this it defaulted to vitest /
|
|
65
|
+
// npm even on Python / Go / Rust projects (sesgo-de-stack audit).
|
|
66
|
+
if (stack || testFramework) {
|
|
67
|
+
lines.push("## Project Stack — generate shell tests for THIS stack, not generic JS");
|
|
68
|
+
if (stack?.language) lines.push(`- Language: **${stack.language}**`);
|
|
69
|
+
if (stack?.frameworks?.length) lines.push(`- Frameworks: ${stack.frameworks.join(", ")}`);
|
|
70
|
+
if (testFramework?.hasTests && testFramework?.framework) {
|
|
71
|
+
lines.push(`- Test framework already in use: **${testFramework.framework}** — use it for the shell commands.`);
|
|
72
|
+
} else if (stack?.language && stack.language !== "javascript" && stack.language !== "typescript") {
|
|
73
|
+
lines.push(`- Use the canonical test runner for ${stack.language} (pytest / go test / cargo test / rspec). Do NOT emit vitest, jest, or npm commands.`);
|
|
74
|
+
}
|
|
75
|
+
lines.push("");
|
|
76
|
+
}
|
|
77
|
+
lines.push("## Task", task, "", "## HUs that need acceptance_tests", "");
|
|
68
78
|
for (const hu of husNeedingTests) {
|
|
69
79
|
lines.push(`### ${hu.id}: ${hu.title || ""}`);
|
|
70
80
|
lines.push(hu.scope ? `Scope: ${hu.scope}` : "(no scope)");
|
package/src/prompts/coder.js
CHANGED
|
@@ -158,7 +158,28 @@ function renderReviewerFindingsSection(findings, huId) {
|
|
|
158
158
|
].join("\n");
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
// KJC sesgo-de-stack: Karajan used to default every prompt to JS/TS
|
|
162
|
+
// (vitest, npm install, console.log, JSDoc, httpOnly cookies). When
|
|
163
|
+
// the project is Python / Go / Rust / Ruby, that produced absurd
|
|
164
|
+
// outputs (e.g. installing vitest in a pure-python repo to satisfy a
|
|
165
|
+
// hardcoded `npx vitest run` acceptance test). When we know the
|
|
166
|
+
// stack we tell the coder explicitly so it stops assuming.
|
|
167
|
+
function buildStackSection({ stack, testFramework }) {
|
|
168
|
+
if (!stack && !testFramework) return null;
|
|
169
|
+
const lines = ["## Project Stack — match this, don't assume JavaScript"];
|
|
170
|
+
if (stack?.language) lines.push(`- Language: **${stack.language}**`);
|
|
171
|
+
if (stack?.frameworks?.length) lines.push(`- Frameworks: ${stack.frameworks.join(", ")}`);
|
|
172
|
+
if (testFramework?.hasTests && testFramework?.framework) {
|
|
173
|
+
lines.push(`- Test framework already in use: **${testFramework.framework}** (${testFramework.language || stack?.language || "unknown"}). Use it for new tests; do NOT add a JS test framework on top.`);
|
|
174
|
+
} else if (stack?.language && stack.language !== "javascript" && stack.language !== "typescript") {
|
|
175
|
+
lines.push(`- No test framework detected yet. Pick the canonical one for ${stack.language} (e.g. pytest for python, \`go test\` for go, \`cargo test\` for rust, rspec for ruby). Do NOT use vitest / jest / \`npm install\` in a non-JS project.`);
|
|
176
|
+
}
|
|
177
|
+
lines.push("");
|
|
178
|
+
lines.push("If an acceptance_test command on this HU points at a foreign framework (e.g. `npx vitest run` on a python project), REPLACE the command with the equivalent for the project's actual stack and use that to verify your work.");
|
|
179
|
+
return lines.join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSummary = null, coderRules = null, methodology = "tdd", serenaEnabled = false, rtkAvailable = false, deferredContext = null, productContext = null, domainContext = null, plan = null, projectDir = null, language = "en", provider = null, acceptanceTests = null, adrs = null, specSection = null, reviewerFindings = null, huId = null, stack = null, testFramework = null }) {
|
|
162
183
|
const langInstruction = getLanguageInstruction(language);
|
|
163
184
|
// KJC-BUG-0032 (PR-I): hard rule about file paths. Real bug: a HU
|
|
164
185
|
// titled "Initialize project skeleton: create assistant/ directory…"
|
|
@@ -190,10 +211,12 @@ export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSum
|
|
|
190
211
|
"Keep changes minimal and production-ready.",
|
|
191
212
|
"Follow SOLID principles. Write small, focused functions (< 30 lines).",
|
|
192
213
|
"Make atomic commits: 1 logical change = 1 commit. Keep PRs small and reviewable.",
|
|
193
|
-
"Security:
|
|
194
|
-
"
|
|
214
|
+
"Security: validate all input, parameterize queries, never expose secrets. Use the auth pattern that matches the stack (e.g. httpOnly cookies for web sessions).",
|
|
215
|
+
"Use the project's standard logger and type system (no console.log when a structured logger exists; use the project's type idiom — TS types, JSDoc, Python type hints, Go interfaces…).",
|
|
195
216
|
SUBPROCESS_CONSTRAINTS
|
|
196
217
|
];
|
|
218
|
+
const stackRule = buildStackSection({ stack, testFramework });
|
|
219
|
+
if (stackRule) sections.push(stackRule);
|
|
197
220
|
if (projectDirRule) sections.push(projectDirRule);
|
|
198
221
|
|
|
199
222
|
if (serenaEnabled) {
|
|
@@ -268,7 +291,7 @@ export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSum
|
|
|
268
291
|
"1. Find the relevant file(s) in the project",
|
|
269
292
|
"2. Make the specific code change needed to resolve the issue",
|
|
270
293
|
"3. If tests are missing, write them",
|
|
271
|
-
"4. If dependencies are needed, install them (npm install)",
|
|
294
|
+
"4. If dependencies are needed, install them with the project's package manager (npm install / pip install / go get / cargo add — pick the one that matches the stack above)",
|
|
272
295
|
"5. Do NOT skip any issue — all must be resolved"
|
|
273
296
|
);
|
|
274
297
|
}
|
package/src/roles/agent-role.js
CHANGED
|
@@ -17,6 +17,19 @@ import { BaseRole } from "./base-role.js";
|
|
|
17
17
|
import { createAgent as defaultCreateAgent } from "../agents/index.js";
|
|
18
18
|
import { withBrainRecovery } from "../brain/with-brain-recovery.js";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Silence timeout (ms) for an agent subprocess, from
|
|
22
|
+
* config.session.max_agent_silence_minutes. runCommand kills a child
|
|
23
|
+
* that produces no output for this long — without it a hung coder /
|
|
24
|
+
* reviewer leaves `kj run` waiting forever. Returns null when unset.
|
|
25
|
+
* @param {object} config
|
|
26
|
+
* @returns {number|null}
|
|
27
|
+
*/
|
|
28
|
+
export function resolveAgentSilenceTimeoutMs(config) {
|
|
29
|
+
const minutes = Number(config?.session?.max_agent_silence_minutes);
|
|
30
|
+
return Number.isFinite(minutes) && minutes > 0 ? Math.round(minutes * 60 * 1000) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
export class AgentRole extends BaseRole {
|
|
21
34
|
constructor({ name, config, logger, emitter = null, createAgentFn = null }) {
|
|
22
35
|
super({ name, config, logger, emitter });
|
|
@@ -116,6 +129,11 @@ export class AgentRole extends BaseRole {
|
|
|
116
129
|
|
|
117
130
|
const runArgs = { prompt, role: this.name };
|
|
118
131
|
if (onOutput) runArgs.onOutput = onOutput;
|
|
132
|
+
// Kill a hung agent: without a silence timeout a coder / reviewer
|
|
133
|
+
// that stalls with no output left `kj run` waiting indefinitely.
|
|
134
|
+
// PlannerRole set this itself; every other role was unprotected.
|
|
135
|
+
const silenceTimeoutMs = resolveAgentSilenceTimeoutMs(this.config);
|
|
136
|
+
if (silenceTimeoutMs) runArgs.silenceTimeoutMs = silenceTimeoutMs;
|
|
119
137
|
|
|
120
138
|
// KJC-TSK-0413 step B: TODAS las roles que heredan de AgentRole pasan
|
|
121
139
|
// por Brain Recovery aquí — cubre AuditRole, SecurityRole, HuReviewerRole,
|
|
@@ -130,13 +148,18 @@ export class AgentRole extends BaseRole {
|
|
|
130
148
|
provider,
|
|
131
149
|
emitter: this.emitter,
|
|
132
150
|
logger: this.logger,
|
|
151
|
+
// KJC: sessionState lets withBrainRecovery persist a hibernating run
|
|
152
|
+
// to ~/.kj/standby/ so it can be resumed. Without it a quota-exhausted
|
|
153
|
+
// run had nothing to resume from. Caller (coder-stage etc.) passes it
|
|
154
|
+
// through the input object; extractInput forwards it via `...input`.
|
|
155
|
+
sessionState: extracted.sessionState || this.context?.sessionState || null,
|
|
133
156
|
});
|
|
134
157
|
|
|
135
158
|
if (!result.ok) {
|
|
136
159
|
const recoveryNote = result.recovery?.class ? ` [${result.recovery.class}]` : "";
|
|
137
160
|
return {
|
|
138
161
|
ok: false,
|
|
139
|
-
result: { error: result.error || result.output || `${this.name} failed${recoveryNote}`, provider, recovery: result.recovery, action: result.action },
|
|
162
|
+
result: { error: result.error || result.output || `${this.name} failed${recoveryNote}`, provider, recovery: result.recovery, action: result.action, standbyFile: result.standbyFile || null },
|
|
140
163
|
summary: `${this.name} failed${recoveryNote}: ${result.recovery?.message || result.error || "unknown error"}`,
|
|
141
164
|
usage: result.usage
|
|
142
165
|
};
|
package/src/roles/coder-role.js
CHANGED
|
@@ -2,6 +2,8 @@ import { AgentRole } from "./agent-role.js";
|
|
|
2
2
|
import { buildCoderPrompt } from "../prompts/coder.js";
|
|
3
3
|
import { isHostAgent } from "../utils/agent-detect.js";
|
|
4
4
|
import { HostAgent } from "../agents/host-agent.js";
|
|
5
|
+
import { detectProjectStack } from "../utils/stack-detect.js";
|
|
6
|
+
import { detectTestFramework } from "../utils/project-detect.js";
|
|
5
7
|
|
|
6
8
|
export class CoderRole extends AgentRole {
|
|
7
9
|
constructor(opts) {
|
|
@@ -54,6 +56,16 @@ export class CoderRole extends AgentRole {
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
async buildPrompt({ task, reviewerFeedback, sonarSummary, deferredContext, acceptanceTests, adrs, specSection, reviewerFindings, huId }) {
|
|
59
|
+
const projectDir = this.config?.projectDir || null;
|
|
60
|
+
// KJC sesgo-de-stack: pasar stack/testFramework al prompt para que
|
|
61
|
+
// el coder no asuma JS/vitest en proyectos Python / Go / Rust /
|
|
62
|
+
// Ruby. audit-role.js y tester-role.js ya lo hacen así.
|
|
63
|
+
let stack = null;
|
|
64
|
+
let testFramework = null;
|
|
65
|
+
if (projectDir) {
|
|
66
|
+
try { stack = await detectProjectStack(projectDir); } catch { /* best-effort */ }
|
|
67
|
+
try { testFramework = await detectTestFramework(projectDir); } catch { /* best-effort */ }
|
|
68
|
+
}
|
|
57
69
|
const prompt = await buildCoderPrompt({
|
|
58
70
|
task, reviewerFeedback, sonarSummary, deferredContext, acceptanceTests,
|
|
59
71
|
adrs, specSection, reviewerFindings, huId,
|
|
@@ -63,7 +75,8 @@ export class CoderRole extends AgentRole {
|
|
|
63
75
|
rtkAvailable: Boolean(this.config?.rtk?.available),
|
|
64
76
|
productContext: this.config?.productContext || null,
|
|
65
77
|
domainContext: this.config?.domainContext || null,
|
|
66
|
-
projectDir
|
|
78
|
+
projectDir,
|
|
79
|
+
stack, testFramework,
|
|
67
80
|
provider: this._resolvedProvider || (typeof this.resolveProvider === "function" ? this.resolveProvider() : null)
|
|
68
81
|
});
|
|
69
82
|
return { prompt };
|
package/src/roles/triage-role.js
CHANGED
|
@@ -61,20 +61,39 @@ export class TriageRole extends AgentRole {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
handleParseNull(agentResult, provider) {
|
|
64
|
-
return {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
};
|
|
64
|
+
return this.degradedTriageResult({
|
|
65
|
+
agentResult, provider,
|
|
66
|
+
reason: "parse-null",
|
|
67
|
+
reasoning: "Unstructured output, using safe defaults.",
|
|
68
|
+
message: "[triage] LLM output was unstructured — falling back to defaults; the pipeline may skip optional roles.",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
handleParseError(err, agentResult, provider) {
|
|
73
|
+
return this.degradedTriageResult({
|
|
74
|
+
agentResult, provider,
|
|
75
|
+
reason: "parse-error",
|
|
76
|
+
reasoning: `Failed to parse triage output (${err?.message || "unknown error"}), using safe defaults.`,
|
|
77
|
+
message: `[triage] Could not parse LLM output — falling back to defaults: ${err?.message || "unknown"}.`,
|
|
78
|
+
});
|
|
70
79
|
}
|
|
71
80
|
|
|
72
|
-
|
|
81
|
+
// Triage degradation is what made a complex task quietly run as
|
|
82
|
+
// `level: "medium", roles: ["reviewer"]` — researcher / architect /
|
|
83
|
+
// security / tester silently skipped. The fallback is preserved so
|
|
84
|
+
// the pipeline still moves, but the user (and any board / kj-tail
|
|
85
|
+
// listener) now learns it happened. result.degraded = true lets
|
|
86
|
+
// downstream code react if it wants.
|
|
87
|
+
// Same shape as before so downstream cost / event accounting is
|
|
88
|
+
// byte-identical; the only change is the loud logger.warn, which is
|
|
89
|
+
// what makes the silent degradation visible to the user / kj-tail.
|
|
90
|
+
degradedTriageResult({ agentResult, provider, reason: _reason, reasoning, message }) {
|
|
91
|
+
this.logger?.warn?.(message);
|
|
73
92
|
return {
|
|
74
93
|
ok: true,
|
|
75
|
-
result: { ...FALLBACK_RESULT, reasoning
|
|
94
|
+
result: { ...FALLBACK_RESULT, reasoning, provider, raw: agentResult.output },
|
|
76
95
|
summary: "Triage complete (fallback defaults)",
|
|
77
|
-
usage: agentResult.usage
|
|
96
|
+
usage: agentResult.usage,
|
|
78
97
|
};
|
|
79
98
|
}
|
|
80
99
|
}
|
package/src/session/store.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { ensureDir, exists } from "../utils/fs.js";
|
|
4
4
|
import { getSessionRoot } from "../utils/paths.js";
|
|
5
|
+
import { writeJsonAtomic } from "../utils/atomic-write.js";
|
|
5
6
|
|
|
6
7
|
const SESSION_ROOT = getSessionRoot();
|
|
7
8
|
|
|
@@ -31,7 +32,7 @@ export async function saveSession(session) {
|
|
|
31
32
|
const dir = path.join(SESSION_ROOT, session.id);
|
|
32
33
|
await ensureDir(dir);
|
|
33
34
|
session.updated_at = new Date().toISOString();
|
|
34
|
-
await
|
|
35
|
+
await writeJsonAtomic(path.join(dir, "session.json"), session);
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export async function loadSession(sessionId) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Atomic JSON writes for persistent state (resilience audit, Phase 2).
|
|
2
|
+
//
|
|
3
|
+
// A plain `writeFile(path, JSON.stringify(...))` truncates the live file
|
|
4
|
+
// before the new content lands. If the process is killed mid-write
|
|
5
|
+
// (SIGKILL, crash, power loss) the file is left truncated and the only
|
|
6
|
+
// good copy is already gone — the plan / session / standby JSON
|
|
7
|
+
// silently corrupts.
|
|
8
|
+
//
|
|
9
|
+
// write-temp + rename fixes it: `rename` is atomic on the same
|
|
10
|
+
// filesystem, so a reader sees either the old file or the complete new
|
|
11
|
+
// one, never a half-written one. The temp file sits next to the target
|
|
12
|
+
// (same dir → same FS) and is uniquely named so concurrent writers do
|
|
13
|
+
// not clobber each other's temp. A crash before the rename leaves an
|
|
14
|
+
// orphan `.tmp` but never a corrupt target.
|
|
15
|
+
|
|
16
|
+
import { writeFileSync, renameSync } from "node:fs";
|
|
17
|
+
import { writeFile, rename } from "node:fs/promises";
|
|
18
|
+
|
|
19
|
+
function tempPath(target) {
|
|
20
|
+
return `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Atomically write `obj` as pretty JSON to `target` (synchronous).
|
|
25
|
+
* @param {string} target - destination path; its directory must exist
|
|
26
|
+
* @param {unknown} obj
|
|
27
|
+
*/
|
|
28
|
+
export function writeJsonAtomicSync(target, obj) {
|
|
29
|
+
const tmp = tempPath(target);
|
|
30
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2), "utf-8");
|
|
31
|
+
renameSync(tmp, target);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Atomically write `obj` as pretty JSON to `target` (async).
|
|
36
|
+
* @param {string} target - destination path; its directory must exist
|
|
37
|
+
* @param {unknown} obj
|
|
38
|
+
*/
|
|
39
|
+
export async function writeJsonAtomic(target, obj) {
|
|
40
|
+
const tmp = tempPath(target);
|
|
41
|
+
await writeFile(tmp, JSON.stringify(obj, null, 2), "utf-8");
|
|
42
|
+
await rename(tmp, target);
|
|
43
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prints — as the LAST line of a stopped `kj run` / `kj plan` — the exact
|
|
3
|
+
* command to resume it. A halted run that never tells you how to restart
|
|
4
|
+
* it is the resilience gap this closes (resilience audit, Phase 1).
|
|
5
|
+
*
|
|
6
|
+
* Quota hibernation → `kj standby resume <id>`
|
|
7
|
+
* Solomon pause → `kj resume <id> --answer "..."`
|
|
8
|
+
* Stopped / failed → `kj resume <id>`
|
|
9
|
+
* Success → nothing.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} result - the flow / command result
|
|
12
|
+
* @param {object} [opts]
|
|
13
|
+
* @param {(line:string)=>void} [opts.print] - sink, default console.log
|
|
14
|
+
*/
|
|
15
|
+
export function printResumeHint(result, { print = console.log } = {}) {
|
|
16
|
+
if (!result || typeof result !== "object") return;
|
|
17
|
+
const id = result.sessionId || result.session?.id || null;
|
|
18
|
+
|
|
19
|
+
if (result.hibernated === true) {
|
|
20
|
+
print("");
|
|
21
|
+
print("⏸ Run hibernated — provider quota exhausted.");
|
|
22
|
+
print(` Resume when the quota resets: kj standby resume ${id || "<session-id>"}`);
|
|
23
|
+
if (!id) print(" (run `kj standby list` to find the id)");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (result.paused === true && id) {
|
|
28
|
+
print("");
|
|
29
|
+
print("⏸ Run paused — waiting for your input.");
|
|
30
|
+
print(` Resume: kj resume ${id} --answer "<your answer>"`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Stopped / failed / aborted but with a session on record → resumable.
|
|
35
|
+
// A successful run has approved === true and prints nothing.
|
|
36
|
+
if (id && result.approved !== true && result.paused !== true) {
|
|
37
|
+
print("");
|
|
38
|
+
print("✗ Run stopped before finishing.");
|
|
39
|
+
print(` Resume: kj resume ${id}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/utils/process.js
CHANGED
|
@@ -152,6 +152,24 @@ export async function runCommand(command, args = [], options = {}) {
|
|
|
152
152
|
function enrichResult(result, stdoutAccum, _stderrAccum) {
|
|
153
153
|
if (result.timedOut) return result;
|
|
154
154
|
|
|
155
|
+
// execa with reject:false RESOLVES (does not throw) on a spawn
|
|
156
|
+
// failure — ENOENT (binary not in PATH), EACCES, etc. The result
|
|
157
|
+
// then carries exitCode null/undefined, an empty stderr, and the
|
|
158
|
+
// real cause only in `code` / `shortMessage`. Surface it: a missing
|
|
159
|
+
// agent CLI used to fail with a completely empty error message,
|
|
160
|
+
// leaving the user with "coder failed" and no idea what to install.
|
|
161
|
+
if (result.failed && result.exitCode == null && !result.signal) {
|
|
162
|
+
const reason = result.shortMessage || result.message
|
|
163
|
+
|| `spawn failed${result.code ? ` (${result.code})` : ""}`;
|
|
164
|
+
return {
|
|
165
|
+
...result,
|
|
166
|
+
exitCode: 1,
|
|
167
|
+
stdout: result.stdout || stdoutAccum,
|
|
168
|
+
stderr: result.stderr || reason,
|
|
169
|
+
spawnError: result.code || true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
155
173
|
const killed = result.killed || !!result.signal;
|
|
156
174
|
const signal = result.signal || null;
|
|
157
175
|
|
|
@@ -39,6 +39,26 @@ export function parseCooldown(message) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// 5. Claude Code session / weekly limit: "resets 10:10pm" / "resets 4am"
|
|
43
|
+
// (12-hour clock, no date). Resolve to the next wall-clock occurrence
|
|
44
|
+
// of that time in the machine's local timezone. Claude Code reports
|
|
45
|
+
// the reset in the user's local TZ and kj runs on the same machine,
|
|
46
|
+
// so the parenthesised "(Europe/Madrid)" hint is ignored on purpose.
|
|
47
|
+
const ampmMatch = /reset(?:s|ting)?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*([ap]m)\b/i.exec(
|
|
48
|
+
message
|
|
49
|
+
);
|
|
50
|
+
if (ampmMatch) {
|
|
51
|
+
let hour = Number.parseInt(ampmMatch[1], 10) % 12;
|
|
52
|
+
if (/pm/i.test(ampmMatch[3])) hour += 12;
|
|
53
|
+
const minute = ampmMatch[2] ? Number.parseInt(ampmMatch[2], 10) : 0;
|
|
54
|
+
if (hour < 24 && minute < 60) {
|
|
55
|
+
const target = new Date();
|
|
56
|
+
target.setHours(hour, minute, 0, 0);
|
|
57
|
+
if (target.getTime() <= Date.now()) target.setDate(target.getDate() + 1);
|
|
58
|
+
return { cooldownUntil: target.toISOString(), cooldownMs: target.getTime() - Date.now() };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
42
62
|
// 2. Relative seconds: "retry after 120 seconds" / "retry in 120s" / "Retry-After: 120"
|
|
43
63
|
const secMatch = /(?:retry[\s-]*after|retry\s+in|wait)\s*:?\s*(\d+)\s*(?:seconds?|secs?|s\b)/i.exec(
|
|
44
64
|
message
|
|
@@ -69,6 +89,8 @@ const RATE_LIMIT_PATTERNS = [
|
|
|
69
89
|
{ pattern: /usage limit/i, agent: "claude" },
|
|
70
90
|
{ pattern: /plan's usage limit/i, agent: "claude" },
|
|
71
91
|
{ pattern: /Claude Pro usage limit/i, agent: "claude" },
|
|
92
|
+
{ pattern: /session limit/i, agent: "claude" },
|
|
93
|
+
{ pattern: /weekly limit/i, agent: "claude" },
|
|
72
94
|
|
|
73
95
|
// OpenAI / Codex CLI
|
|
74
96
|
{ pattern: /exceeded your current quota/i, agent: "codex" },
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import fs from "node:fs";
|
|
21
21
|
import path from "node:path";
|
|
22
22
|
import os from "node:os";
|
|
23
|
+
import { writeJsonAtomicSync } from "./atomic-write.js";
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Directorio raíz donde viven los registros de run.
|
|
@@ -54,7 +55,7 @@ export function registerRun(info = {}) {
|
|
|
54
55
|
source: info.source || "cli",
|
|
55
56
|
startedAt: new Date().toISOString(),
|
|
56
57
|
};
|
|
57
|
-
|
|
58
|
+
writeJsonAtomicSync(filePath, entry);
|
|
58
59
|
return runId;
|
|
59
60
|
} catch {
|
|
60
61
|
return null;
|