karajan-code 2.17.1 β 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.
- package/README.md +5 -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/commands/plan/generate.js +22 -0
- package/src/commands/run.js +5 -0
- package/src/config/loader.js +27 -3
- package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +21 -0
- 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/roles/agent-role.js +24 -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/README.md
CHANGED
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v2.
|
|
26
|
+
> **v2.18.0 released** β Minor. Closes the **resilience audit** triggered by the public launch: 15 PRs across 5 phases hardening Karajan against the silent-failure family of bugs β *the problem is not that something fails, the problem is failing without telling the user why.* **Phase 1 β hibernation end to end** (#756β#759): a quota cap is classified as a recoverable quota class (incl. Claude Code's `"You've hit your session limit Β· resets 10:10pm"`); `withBrainRecovery` persists a standby JSON; the orchestrator consumes `action:"hibernate"`, seals the session `hibernated` (resumable) instead of `failed`; the last line printed is the exact resume command (`kj standby resume <id>`). **Phase 2 β don't lie** (#761β#763): `runCommand` surfaces ENOENT so a missing agent CLI gets an actionable error (not an empty one); `silenceTimeoutMs` is forwarded to every role so a hung agent is killed, not waited on forever; all 6 state-file writers go through `writeJsonAtomic` (write-temp + rename) β interrupted writes can no longer truncate plans, sessions, standby snapshots. **Phase 3 β don't lose or block** (#764β#767): a corrupt plan JSON is renamed aside with a loud warn (was silently dropped from `kj plan list`); `kj.config.yml` parse errors throw `Invalid YAML in <path>` (used to brick every kj command including `kj doctor`); `injectLoadedPlan` reconciles HUs left in `coding`/`reviewing`/`running` by a killed `kj run` (cross-checked against the run-registry); board SQLite gets `busy_timeout`, a `user_version` schema gate and corruption recovery. **Phase 4 β don't degrade silently** (#768β#769): `TriageRole` warns loudly when LLM output is unparseable (used to skip researcher/architect/security/tester with a chirpy "fallback defaults" summary); `verifyCoderOutput` distinguishes a git failure from "the coder did nothing" (`gitError` + `retryStrategy: null`) β no more iterations blaming the agent for infra. **Phase 5 β safety net** (#770): a `tests/resilience/` suite indexes every silent-failure mode and pins each one with a test, plus an end-to-end tripwire for the whole quota β hibernate β resume flow. Plus CI runs the `packages/hu-board` suite on every PR (#755). 4 959/4 959 tests passing across 416 files. Safe upgrade from 2.17.2.
|
|
27
|
+
>
|
|
28
|
+
> **v2.17.2** β Patch. Wires quota-exhaustion **hibernation end to end**. A `kj run` / `kj plan` that hits a provider session or usage cap used to abort the task with an opaque `UNKNOWN_FATAL`; now it suspends and tells you how to resume. **Session-limit classification (#756)**: `"You've hit your session limit Β· resets 10:10pm"` matched no rate-limit pattern β `session limit`/`weekly limit` added, and `parseCooldown` learns the 12-hour `resets 10:10pm` clock. **Hibernation persists (#757)**: `withBrainRecovery` only wrote `~/.kj/standby/<id>.json` with a `sessionState`, which no caller passed β new `buildStandbyState()` assembles it with an allowlisted env subset (never the full `process.env`). **Orchestrator consumes `action:"hibernate"` (#758)**: no code checked for it, so a hibernation sealed the HU `failed`; the coder/refactorer stages now stop cleanly and the session is sealed `hibernated` (resumable). **Resume hint (#759)**: a stopped run's last line is now the exact command β `kj standby resume <id>` for a hibernation, `kj resume <id>` otherwise. Plus CI now runs the `packages/hu-board` suite (#755). 4 931/4 931 tests passing across 410 files. Safe upgrade from 2.17.1.
|
|
29
|
+
>
|
|
30
|
+
> **v2.17.1** β Patch. Fixes **KJC-BUG-0055**: a project deleted from the HU Board (ποΈ) no longer resurrects on the next `kj plan` or board restart. Four independent leaks closed: (1) **`sync.js` temporal gate** β the unconditional `removeTombstone` from KJC-BUG-0050 becomes a `plan.updatedAt > tombstone.deleted_at` comparison, so a tombstoned project revives only when the plan is genuinely newer than the delete; (2) **`ephemeral-cleaner.js`** now writes a tombstone and `rm -rf`'s `hu-stories/`, `sessions/` and `~/.kj/plans/` dirs when wiping ephemeral projects at boot β previously the orphan directories revived the project on the next scan; (3) **`fullScan` boot GC** sweeps orphan tombstoned dirs (the manual DB-wipe case); (4) **`DELETE /api/projects/:id`** honours `KJ_PLANS_DIR` instead of the hardcoded path. New `getTombstone()` helper in `db.js`. **Also fixes a silent `kj board start` failure (#753)**: the daemon's entry-point guard compared `import.meta.url` to a hand-built `file://` path, which wrongly returned false on Windows, symlinked/global installs and paths with spaces β the board exited 0 with an empty log. `server.js` now trusts a `KJ_BOARD_DAEMON` launcher flag, adds `uncaughtException`/`unhandledRejection` handlers and an actionable `better-sqlite3` load error; `board.js` detects an early daemon exit instead of reporting a phantom PID. 4 909/4 909 tests passing across 408 files. Safe upgrade from 2.17.0.
|
|
27
31
|
>
|
|
28
32
|
> **v2.17.0** β Minor. `kj audit` gains two new deterministic structural collectors and the v2.16 Sonar false-positive filter is generalised to apply across every collector. **Knip dead-exports collector** (codeQuality dim): reports unused exports/types (MINOR) and unused files (MAJOR), stack-aware (JS/TS only, needs package.json), subprocess via `--reporter json`, 120s timeout. **Madge circular-import collector** (architecture dim): detects import cycles, severity by chain length (β₯4 files = MAJOR), honours tsconfig.json path aliases, 60s timeout. **Generalised FP filter**: every collector (sonar, knip, madge, osv, semgrep) now uses the same `config.audit.false_positives` shape with `tool` field plus inline marker `// karajan-audit-ignore: <tool>:<ruleId>`; legacy `config.sonar.false_positives` and `// karajan-sonar-ignore:` keep working. **Built-in catalogue** ships 4 entries by default (knip:unused-files in tests/fixtures + examples, knip:unused-exports on barrel files, madge cycles in node_modules). **BREAKING(engines)**: Node `>=20.10` β `>=20.19` (knip 6.x requirement; same flavour as the v2.8 18β20.10 bump). 4 872/4 872 tests passing across 402 files. Safe upgrade from 2.16.0 if you're on Node β₯ 20.19.
|
|
29
33
|
>
|
package/package.json
CHANGED
|
@@ -33,7 +33,11 @@ const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
33
33
|
|
|
34
34
|
const AUTH_PATTERNS = /\b401\b|\b403\b|unauthorized|invalid\s+api\s+key|authentication\s+failed|expired\s+token/i;
|
|
35
35
|
const SILENCED_PATTERNS = /killed\s+after\s+\d+\s*ms|silence\s*timeout|no\s+output\s+for\s+\d+/i;
|
|
36
|
-
|
|
36
|
+
// `session\s+limit` / `weekly\s+limit` cover Claude Code's usage caps
|
|
37
|
+
// ("You've hit your session limit Β· resets 10:10pm"). Without them the
|
|
38
|
+
// message fell through to UNKNOWN_FATAL and the run aborted instead of
|
|
39
|
+
// hibernating until the reset.
|
|
40
|
+
const RATE_LIMIT_PATTERNS = /usage\s+limit|rate\s*limit|too\s+many\s+requests|\b429\b|throttl|exceeded\s+your\s+current\s+quota|resource\s+exhausted|quota\s+exceeded|token\s+limit\s+reached|monthly\s+limit|weekly\s+limit|daily\s+limit|session\s+limit/i;
|
|
37
41
|
const API_DOWN_PATTERNS = /\b50[0-4]\b|bad\s+gateway|service\s+unavailable|gateway\s+timeout|overloaded|internal\s+server\s+error/i;
|
|
38
42
|
const NETWORK_PATTERNS = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|socket\s+hang\s+up|fetch\s+failed|network\s+error/i;
|
|
39
43
|
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import os from "node:os";
|
|
18
|
+
import { writeJsonAtomicSync } from "../utils/atomic-write.js";
|
|
18
19
|
|
|
19
20
|
const FILENAME_RE = /^[a-zA-Z0-9._-]+\.json$/;
|
|
20
21
|
|
|
@@ -56,10 +57,46 @@ export function persistStandby(state) {
|
|
|
56
57
|
createdAt: state.createdAt || new Date().toISOString(),
|
|
57
58
|
persistedAt: new Date().toISOString(),
|
|
58
59
|
};
|
|
59
|
-
|
|
60
|
+
writeJsonAtomicSync(file, payload);
|
|
60
61
|
return file;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
// Only these env vars are carried into the standby snapshot. NEVER
|
|
65
|
+
// persist the whole `process.env` β that would leak API keys and other
|
|
66
|
+
// secrets into ~/.kj/standby/*.json. `kj standby resume` re-spawns with
|
|
67
|
+
// the live env merged on top, so the snapshot only needs what is needed
|
|
68
|
+
// to relaunch from the right place.
|
|
69
|
+
const STANDBY_ENV_ALLOWLIST = ["KJ_HOME", "HOME", "PATH"];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Builds the `sessionState` object that `withBrainRecovery` persists when
|
|
73
|
+
* a run hibernates. Pulls the session id, the relaunch metadata and an
|
|
74
|
+
* allowlisted env subset so `kj standby resume` can re-spawn the command.
|
|
75
|
+
* @param {object} args
|
|
76
|
+
* @param {object} args.session - the pipeline session ({ id, plan_id? })
|
|
77
|
+
* @param {object} [args.config] - resolved config ({ projectDir?, plan? })
|
|
78
|
+
* @param {string|null} [args.huId]
|
|
79
|
+
* @returns {object|null} sessionState, or null when there is no session id
|
|
80
|
+
*/
|
|
81
|
+
export function buildStandbyState({ session, config = {}, huId = null } = {}) {
|
|
82
|
+
if (!session?.id) return null;
|
|
83
|
+
const env = {};
|
|
84
|
+
for (const key of STANDBY_ENV_ALLOWLIST) {
|
|
85
|
+
if (process.env[key] != null) env[key] = process.env[key];
|
|
86
|
+
}
|
|
87
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
88
|
+
if (key.startsWith("KJ_") && value != null) env[key] = value;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
sessionId: session.id,
|
|
92
|
+
planId: session.plan_id || config.plan || null,
|
|
93
|
+
huId: huId || null,
|
|
94
|
+
argv: process.argv.slice(2),
|
|
95
|
+
cwd: config.projectDir || process.cwd(),
|
|
96
|
+
env,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
63
100
|
/**
|
|
64
101
|
* @param {string} sessionId
|
|
65
102
|
* @returns {object|null}
|
|
@@ -11,6 +11,8 @@ import { applyReviewerFeedback, applyFixerPatch } from "../../plan/plan-fixer.js
|
|
|
11
11
|
import { runStructuralPass } from "../../plan/plan-structural-pass.js";
|
|
12
12
|
import { recommendModelsForHu, complexityFromTaskType } from "../../hu/model-router.js";
|
|
13
13
|
import { withBrainRecovery } from "../../brain/with-brain-recovery.js";
|
|
14
|
+
import { buildStandbyState } from "../../brain/standby-store.js";
|
|
15
|
+
import { printResumeHint } from "../../utils/display/resume-hint.js";
|
|
14
16
|
import { formatPlan, formatHuTable } from "./_shared.js";
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -61,6 +63,13 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
|
|
|
61
63
|
// self-fix iter N por delante.
|
|
62
64
|
const progress = createCliProgressReporter({ role: "planner:initial", quiet: Boolean(json) });
|
|
63
65
|
let result;
|
|
66
|
+
// `kj plan` has no pipeline session, so synthesise a standby id
|
|
67
|
+
// (`plan_<stamp>`). If the planner hits a quota cap, withBrainRecovery
|
|
68
|
+
// persists it and `kj standby resume` re-spawns via the saved argv.
|
|
69
|
+
const plannerSessionState = buildStandbyState({
|
|
70
|
+
session: { id: `plan_${new Date().toISOString().replaceAll(/[:.]/g, "-")}` },
|
|
71
|
+
config,
|
|
72
|
+
});
|
|
64
73
|
try {
|
|
65
74
|
// KJC-TSK-0413: planner inicial pasa por Brain Recovery β clasifica
|
|
66
75
|
// 401/429/5xx/SILENCED/etc y decide retry/standby/hibernate/abort.
|
|
@@ -69,6 +78,7 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
|
|
|
69
78
|
taskArgs: { prompt, role: "planner", silenceTimeoutMs, timeoutMs, onOutput: progress.onOutput },
|
|
70
79
|
role: "planner",
|
|
71
80
|
logger,
|
|
81
|
+
sessionState: plannerSessionState,
|
|
72
82
|
});
|
|
73
83
|
progress.finish(result.ok ? "done" : (result.action || "failed"));
|
|
74
84
|
} catch (err) {
|
|
@@ -76,6 +86,18 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
|
|
|
76
86
|
throw err;
|
|
77
87
|
}
|
|
78
88
|
|
|
89
|
+
if (!result.ok && result.action === "hibernate") {
|
|
90
|
+
// Quota cap: Brain Recovery persisted a standby snapshot. Stop
|
|
91
|
+
// cleanly with a resume hint instead of throwing a generic error.
|
|
92
|
+
const hibResult = {
|
|
93
|
+
ok: false, hibernated: true,
|
|
94
|
+
sessionId: plannerSessionState?.sessionId || null,
|
|
95
|
+
standbyFile: result.standbyFile || null,
|
|
96
|
+
};
|
|
97
|
+
if (!json) printResumeHint(hibResult);
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
return hibResult;
|
|
100
|
+
}
|
|
79
101
|
if (!result.ok) throw new Error(result.error || result.output || "Planner failed");
|
|
80
102
|
|
|
81
103
|
const parsed = parseMaybeJsonString(result.output);
|
package/src/commands/run.js
CHANGED
|
@@ -7,6 +7,7 @@ import { withCliRunLog } from "../utils/cli-run-log.js";
|
|
|
7
7
|
import { registerRun, unregisterRun } from "../utils/run-registry.js";
|
|
8
8
|
import { printHeader } from "../utils/display/header.js";
|
|
9
9
|
import { printEvent } from "../utils/display/event-handlers.js";
|
|
10
|
+
import { printResumeHint } from "../utils/display/resume-hint.js";
|
|
10
11
|
import { resolveRole } from "../config.js";
|
|
11
12
|
import { parseCardId } from "../planning-game/adapter.js";
|
|
12
13
|
import { confirmCwd } from "../utils/cwd-confirm.js";
|
|
@@ -175,6 +176,10 @@ export async function runCommandHandler({ task, config, logger, flags }) {
|
|
|
175
176
|
|
|
176
177
|
if (jsonMode) {
|
|
177
178
|
console.log(JSON.stringify(result, null, 2));
|
|
179
|
+
} else {
|
|
180
|
+
// Last line printed: if the run stopped (hibernated / paused /
|
|
181
|
+
// failed), tell the user the exact command to resume it.
|
|
182
|
+
printResumeHint(result);
|
|
178
183
|
}
|
|
179
184
|
return { ok: !result?.paused && result?.approved !== false };
|
|
180
185
|
});
|
package/src/config/loader.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 {
|
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 || ""));
|
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/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;
|