karajan-code 2.17.0 → 2.18.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.
@@ -28,6 +28,30 @@ import { safeParseConfig, formatConfigIssues } from "./schema.js";
28
28
  import { DEFAULTS } from "./defaults.js";
29
29
  import { resolveTestHarness } from "./test-harness.js";
30
30
 
31
+ /**
32
+ * Parse a YAML config file with an actionable error message.
33
+ * Plain `yaml.load` throws `YAMLException: bad indentation ...` with NO
34
+ * file path, so a single bad edit bricked the entire CLI (including
35
+ * `kj doctor`, the one command meant to diagnose it). Wrapping every
36
+ * read site in the same prefix means the user always knows which file
37
+ * they need to fix.
38
+ * @param {string} raw - file contents
39
+ * @param {string} filePath - absolute path (for the error message)
40
+ * @returns {object}
41
+ */
42
+ function parseYamlOrThrow(raw, filePath) {
43
+ try {
44
+ return yaml.load(raw, { json: true }) || {};
45
+ } catch (err) {
46
+ const detail = err?.message || String(err);
47
+ const e = new Error(`Invalid YAML in ${filePath}:\n ${detail}`);
48
+ e.cause = err;
49
+ e.code = "INVALID_YAML";
50
+ e.path = filePath;
51
+ throw e;
52
+ }
53
+ }
54
+
31
55
  export function mergeDeep(base, override) {
32
56
  const output = { ...base };
33
57
  for (const [key, value] of Object.entries(override || {})) {
@@ -56,7 +80,7 @@ export async function loadProjectConfig(projectDir = process.cwd()) {
56
80
  return null;
57
81
  }
58
82
  const raw = await fs.readFile(projectConfigPath, "utf8");
59
- return yaml.load(raw, { json: true }) || {};
83
+ return parseYamlOrThrow(raw, projectConfigPath);
60
84
  }
61
85
 
62
86
  async function loadProjectPricingOverrides(projectDir = process.cwd()) {
@@ -66,7 +90,7 @@ async function loadProjectPricingOverrides(projectDir = process.cwd()) {
66
90
  }
67
91
 
68
92
  const raw = await fs.readFile(projectConfigPath, "utf8");
69
- const parsed = yaml.load(raw, { json: true }) || {};
93
+ const parsed = parseYamlOrThrow(raw, projectConfigPath);
70
94
  const pricing = parsed?.budget?.pricing;
71
95
  if (!pricing || typeof pricing !== "object") {
72
96
  return null;
@@ -84,7 +108,7 @@ export async function loadConfig(projectDir) {
84
108
  const globalExists = await exists(configPath);
85
109
  if (globalExists) {
86
110
  const raw = await fs.readFile(configPath, "utf8");
87
- globalConfig = yaml.load(raw, { json: true }) || {};
111
+ globalConfig = parseYamlOrThrow(raw, configPath);
88
112
  }
89
113
 
90
114
  // Load project config (.karajan/kj.config.yml)
@@ -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) {
@@ -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
- filesChanged = verif.filesChanged || 0;
194
- } catch { /* ignore verification errors */ }
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({ task: plannedTask, onOutput: refactorerStall.onOutput });
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
- return { files: [], filesChanged: 0, linesAdded: 0, linesDeleted: 0 };
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
- const totalFiles = tracked.filesChanged + untracked.length;
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, ...untracked];
121
+ const allFiles = [...tracked.files, ...untrackedFiles];
93
122
 
94
- const passed = totalFiles >= minFiles && (untracked.length > 0 || totalLines >= minLines);
123
+ const passed = totalFiles >= minFiles && (untrackedFiles.length > 0 || totalLines >= minLines);
95
124
 
96
125
  if (passed) {
97
126
  return {
@@ -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 fs.writeFile(filePath, JSON.stringify(planResult, null, 2), "utf8");
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 fs.writeFile(filePath, JSON.stringify(record, null, 2), "utf8");
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(path.join(dir, f), "utf8"));
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 { /* skip corrupt */ }
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 { /* fall through to alias scan */ }
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(path.join(dir, f), "utf8"));
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 { /* skip corrupt */ }
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(path.join(dir, file), "utf8");
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 { /* skip corrupt */ }
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 || ""));
@@ -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
  };
@@ -61,20 +61,39 @@ export class TriageRole extends AgentRole {
61
61
  }
62
62
 
63
63
  handleParseNull(agentResult, provider) {
64
- return {
65
- ok: true,
66
- result: { ...FALLBACK_RESULT, reasoning: "Unstructured output, using safe defaults.", provider, raw: agentResult.output },
67
- summary: "Triage complete (fallback defaults)",
68
- usage: agentResult.usage
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
- handleParseError(_err, agentResult, provider) {
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: "Failed to parse triage output, using safe defaults.", provider, raw: agentResult.output },
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
  }
@@ -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 fs.writeFile(path.join(dir, "session.json"), JSON.stringify(session, null, 2), "utf8");
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
+ }