karajan-code 2.14.3 → 2.15.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 +1 -1
- package/package.json +1 -1
- package/src/brain/agent-error-classifier.js +101 -0
- package/src/brain/standby-scheduler.js +92 -0
- package/src/brain/standby-store.js +129 -0
- package/src/brain/with-brain-recovery.js +178 -0
- package/src/cli/register-plan.js +29 -0
- package/src/cli/register-standby.js +26 -0
- package/src/cli.js +2 -0
- package/src/commands/architect.js +6 -2
- package/src/commands/discover.js +6 -2
- package/src/commands/init.js +47 -0
- package/src/commands/plan/_shared.js +19 -4
- package/src/commands/plan/fix.js +99 -0
- package/src/commands/plan/generate.js +112 -27
- package/src/commands/plan/index.js +2 -0
- package/src/commands/plan/migrate-result.js +41 -0
- package/src/commands/plan.js +2 -0
- package/src/commands/researcher.js +6 -2
- package/src/commands/run.js +33 -1
- package/src/commands/standby.js +114 -0
- package/src/commands/triage.js +6 -2
- package/src/config/schema.js +40 -0
- package/src/git/hu-snapshot.js +105 -0
- package/src/guards/policy-resolver.js +38 -1
- package/src/hu/lazy-planner.js +6 -1
- package/src/hu/model-router.js +108 -0
- package/src/hu/splitting-generator.js +6 -1
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +47 -8
- package/src/orchestrator/drivers/run-hu-batch.js +30 -4
- package/src/plan/plan-executor.js +13 -4
- package/src/plan/plan-fixer.js +36 -4
- package/src/plan/plan-hu-ops.js +24 -1
- package/src/plan/plan-id.js +20 -0
- package/src/plan/plan-migrate-result.js +24 -0
- package/src/plan/plan-reviewer.js +40 -3
- package/src/plan/plan-schema.js +128 -1
- package/src/plan/plan-store.js +63 -8
- package/src/plan/plan-structural-pass.js +122 -0
- package/src/plan/plan-validation.js +82 -0
- package/src/plan/tests-synthesizer.js +8 -3
- package/src/prompts/planner.js +27 -1
- package/src/roles/agent-role.js +18 -3
- package/src/roles/planner-role.js +10 -3
- package/src/roles/solomon-role.js +10 -4
- package/src/utils/cli-progress.js +58 -1
- package/src/utils/garbage-collector.js +85 -2
- package/src/utils/run-registry.js +115 -0
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v2.
|
|
26
|
+
> **v2.15.0 released** — Minor. Three epics, 30+ commits, ~4 000 LOC. (1) **Brain Recovery** (KJC-PCS-0044): universal error classifier with 7 rich classes (rate_limit_short, quota_daily, quota_monthly, api_down, auth_failed, network_timeout, silenced, unknown_fatal), `withBrainRecovery` wrapper wired into ALL agent invocations (no silent failures), persistent hibernation in `~/.kj/standby/<id>.json` with event-driven scheduler (no polling), `kj standby list` + `kj standby resume`, board reconcile at startup, **fallback chain** when quota exhausted with `retryAfter > 12h` (Claude → Codex → OpenCode → Aider, configurable per role via `kj init`). (2) **Model Routing + Undo** (KJC-PCS-0043): each HU gets `coder_model` + `reviewer_model` with cross-provider review by default (claude↔codex), per-HU override from board modal, OpenCode + Aider as first-class providers, ⏪ Undo button restores files via git snapshots. (3) **Self-Healing Plan** (KJC-PCS-0042): structural integrity pass breaks cycles + cleans orphan refs, smart convergence guard for self-fix loop, `kj plan fix [planId] [--prompt]` for iterating without regenerating, skip Sonar for SPIKE/DOC/RESEARCH HUs, eliminate Failed kanban column. 4 835/4 835 tests passing across 400 files. Safe upgrade from 2.14.3.
|
|
27
27
|
>
|
|
28
28
|
> **v2.14.2** — Patch. More dogfooding fallout from GRETA Plan 2 v2.14.1: the **▶ Run button** on the HU Board appeared on every `pending` card regardless of `blocked_by` (you could launch a HU whose deps don't exist yet — the user caught it instantly), and titles on the board lost their **`[EPICA]` prefix** at some point in v2.14.x evolution, making it impossible to tell at a glance which area of the plan a card belonged to. Fixes: `canRunHu` now requires `blockedBy.length === 0` before showing ▶; the planner prompt now demands `description: "[EPICA] one-sentence description"` with INFRA/SHARED fallbacks. Plus a new doc `docs/task-templates/spec-conventions.md` collecting the 6 SPEC conventions the planner v2.14.x understands (épicas, scope exclusions, transversal deps, reuse, async observers, explicit deps), so users don't have to rediscover them by dogfooding. 2 PRs (#687, #688). 4584/4584 tests passing. Safe upgrade from 2.14.1.
|
|
29
29
|
>
|
package/package.json
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// KJC-TSK-0411: clasificador universal de errores de agente IA.
|
|
2
|
+
//
|
|
3
|
+
// Devuelve clase rica + metadata accionable para que Brain (TSK-0412)
|
|
4
|
+
// decida retry/standby/hibernar/abortar sin tener que repetir lógica
|
|
5
|
+
// de parsing en cada stage.
|
|
6
|
+
//
|
|
7
|
+
// Reutiliza parseCooldown del rate-limit-detector legacy para no
|
|
8
|
+
// duplicar regex. Este módulo extiende la clasificación:
|
|
9
|
+
// - RATE_LIMIT_SHORT vs QUOTA_EXHAUSTED_DAILY se distinguen por
|
|
10
|
+
// cooldownMs (threshold 1h).
|
|
11
|
+
// - SILENCED se detecta por exit 143/null + "killed after Nms".
|
|
12
|
+
// - AUTH_FAILED por 401/403 + "Unauthorized"/"invalid api key".
|
|
13
|
+
// - NETWORK_TIMEOUT por ECONN*/socket hang up sin cooldown.
|
|
14
|
+
|
|
15
|
+
import { parseCooldown } from "../utils/rate-limit-detector.js";
|
|
16
|
+
|
|
17
|
+
export const ERROR_CLASS = Object.freeze({
|
|
18
|
+
RATE_LIMIT_SHORT: "RATE_LIMIT_SHORT",
|
|
19
|
+
QUOTA_EXHAUSTED_DAILY: "QUOTA_EXHAUSTED_DAILY",
|
|
20
|
+
// KJC-TSK-0415: Anthropic Max 20x cambia a $200/mes Agent SDK desde
|
|
21
|
+
// 15-jun-2026. Cuando llegues al cap mensual, el reset es 1-mes —
|
|
22
|
+
// muy distinto del daily de Claude Pro. Esta clase distingue ambos.
|
|
23
|
+
QUOTA_EXHAUSTED_MONTHLY: "QUOTA_EXHAUSTED_MONTHLY",
|
|
24
|
+
API_DOWN: "API_DOWN",
|
|
25
|
+
AUTH_FAILED: "AUTH_FAILED",
|
|
26
|
+
NETWORK_TIMEOUT: "NETWORK_TIMEOUT",
|
|
27
|
+
SILENCED: "SILENCED",
|
|
28
|
+
UNKNOWN_FATAL: "UNKNOWN_FATAL",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
32
|
+
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
const AUTH_PATTERNS = /\b401\b|\b403\b|unauthorized|invalid\s+api\s+key|authentication\s+failed|expired\s+token/i;
|
|
35
|
+
const SILENCED_PATTERNS = /killed\s+after\s+\d+\s*ms|silence\s*timeout|no\s+output\s+for\s+\d+/i;
|
|
36
|
+
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|daily\s+limit/i;
|
|
37
|
+
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
|
+
const NETWORK_PATTERNS = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|socket\s+hang\s+up|fetch\s+failed|network\s+error/i;
|
|
39
|
+
|
|
40
|
+
function pickMessage(combined, pattern) {
|
|
41
|
+
const line = combined.split("\n").find((l) => pattern.test(l));
|
|
42
|
+
return (line || combined).trim().slice(0, 300);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {object} args
|
|
47
|
+
* @param {string} [args.provider] - claude|codex|gemini|opencode|aider
|
|
48
|
+
* @param {string} [args.stdout]
|
|
49
|
+
* @param {string} [args.stderr]
|
|
50
|
+
* @param {number|null} [args.exitCode]
|
|
51
|
+
* @returns {{ class: string, provider: string, message: string, retryAfter: number|null, retryUntil: string|null, recoverable: boolean, exitCode: number|null }}
|
|
52
|
+
*/
|
|
53
|
+
export function classifyAgentError({ provider = "unknown", stdout = "", stderr = "", exitCode = null }) {
|
|
54
|
+
const combined = `${stderr}\n${stdout}`;
|
|
55
|
+
const base = { provider, exitCode, retryAfter: null, retryUntil: null };
|
|
56
|
+
|
|
57
|
+
// 1. AUTH_FAILED tiene prioridad absoluta — no es recuperable, escalar al usuario.
|
|
58
|
+
if (AUTH_PATTERNS.test(combined)) {
|
|
59
|
+
return { ...base, class: ERROR_CLASS.AUTH_FAILED, message: pickMessage(combined, AUTH_PATTERNS), recoverable: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 2. SILENCED: subprocess killed por silenceTimeout. exit=143 (SIGTERM) o null.
|
|
63
|
+
// También detectamos por mensaje aunque exitCode no encaje (resiliente).
|
|
64
|
+
if (SILENCED_PATTERNS.test(combined) || (exitCode === 143 && !combined.trim())) {
|
|
65
|
+
return { ...base, class: ERROR_CLASS.SILENCED, message: pickMessage(combined, SILENCED_PATTERNS) || "Agent silenciado por timeout", recoverable: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 3. RATE_LIMIT con cooldown. Thresholds:
|
|
69
|
+
// cooldown > 7d → MONTHLY (Anthropic Agent SDK $200/mes desde jun-2026)
|
|
70
|
+
// cooldown > 1h → DAILY (Claude Pro daily, OpenAI rate-limit-by-day)
|
|
71
|
+
// cooldown <= 1h → SHORT (rate limit transitorio)
|
|
72
|
+
if (RATE_LIMIT_PATTERNS.test(combined)) {
|
|
73
|
+
const msg = pickMessage(combined, RATE_LIMIT_PATTERNS);
|
|
74
|
+
const { cooldownUntil, cooldownMs } = parseCooldown(msg) ?? {};
|
|
75
|
+
let quotaClass;
|
|
76
|
+
if (cooldownMs != null && cooldownMs > ONE_WEEK_MS) quotaClass = ERROR_CLASS.QUOTA_EXHAUSTED_MONTHLY;
|
|
77
|
+
else if (cooldownMs != null && cooldownMs > ONE_HOUR_MS) quotaClass = ERROR_CLASS.QUOTA_EXHAUSTED_DAILY;
|
|
78
|
+
else quotaClass = ERROR_CLASS.RATE_LIMIT_SHORT;
|
|
79
|
+
return {
|
|
80
|
+
...base,
|
|
81
|
+
class: quotaClass,
|
|
82
|
+
message: msg,
|
|
83
|
+
retryAfter: cooldownMs ?? null,
|
|
84
|
+
retryUntil: cooldownUntil ?? null,
|
|
85
|
+
recoverable: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 4. NETWORK errors antes que API_DOWN — ECONN* es a nivel socket, no respuesta HTTP.
|
|
90
|
+
if (NETWORK_PATTERNS.test(combined)) {
|
|
91
|
+
return { ...base, class: ERROR_CLASS.NETWORK_TIMEOUT, message: pickMessage(combined, NETWORK_PATTERNS), recoverable: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 5. API_DOWN: 5xx, overloaded.
|
|
95
|
+
if (API_DOWN_PATTERNS.test(combined)) {
|
|
96
|
+
return { ...base, class: ERROR_CLASS.API_DOWN, message: pickMessage(combined, API_DOWN_PATTERNS), recoverable: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 6. UNKNOWN_FATAL: salida no clasificada con exitCode != 0.
|
|
100
|
+
return { ...base, class: ERROR_CLASS.UNKNOWN_FATAL, message: combined.trim().slice(0, 300) || `exit ${exitCode}`, recoverable: false };
|
|
101
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// KJC-TSK-0414 PR 1: scheduler event-driven para hibernación.
|
|
2
|
+
//
|
|
3
|
+
// Diseño: setTimeout único por sesión, dispara EXACTAMENTE en cooldownUntil.
|
|
4
|
+
// Cero polling. Si el proceso muere antes del fire, reconcileAll() al
|
|
5
|
+
// arrancar el board re-programa los timeouts pendientes (o reanuda
|
|
6
|
+
// inmediatamente los expirados).
|
|
7
|
+
|
|
8
|
+
import { listPendingStandby, acquireStandbyLock, markStandbyDone } from "./standby-store.js";
|
|
9
|
+
|
|
10
|
+
// Mapa sessionId → NodeJS.Timeout — accesible para test/cancel.
|
|
11
|
+
const _scheduled = new Map();
|
|
12
|
+
|
|
13
|
+
export function _resetScheduledForTests() {
|
|
14
|
+
for (const t of _scheduled.values()) clearTimeout(t);
|
|
15
|
+
_scheduled.clear();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Programa un único setTimeout que dispara `onResume(sessionId)` en cooldownUntil.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} args
|
|
22
|
+
* @param {string} args.sessionId
|
|
23
|
+
* @param {string} args.cooldownUntil - ISO timestamp
|
|
24
|
+
* @param {Function} args.onResume - callback(sessionId) cuando llega el momento
|
|
25
|
+
* @param {object} [args.logger]
|
|
26
|
+
* @returns {{ scheduled: boolean, msUntil?: number, immediate?: boolean }}
|
|
27
|
+
*/
|
|
28
|
+
export function scheduleResume({ sessionId, cooldownUntil, onResume, logger }) {
|
|
29
|
+
if (!sessionId || !cooldownUntil || typeof onResume !== "function") {
|
|
30
|
+
return { scheduled: false };
|
|
31
|
+
}
|
|
32
|
+
cancelScheduled(sessionId);
|
|
33
|
+
const targetMs = new Date(cooldownUntil).getTime();
|
|
34
|
+
if (Number.isNaN(targetMs)) return { scheduled: false };
|
|
35
|
+
const delay = targetMs - Date.now();
|
|
36
|
+
if (delay <= 0) {
|
|
37
|
+
// Ya expiró — reanudar inmediato (caso: board reiniciado tras llegar la hora).
|
|
38
|
+
logger?.info?.(`[standby-scheduler] ${sessionId}: cooldown ya expirado, resume inmediato`);
|
|
39
|
+
Promise.resolve().then(() => onResume(sessionId)).catch(() => { /* swallow */ });
|
|
40
|
+
return { scheduled: true, immediate: true };
|
|
41
|
+
}
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
_scheduled.delete(sessionId);
|
|
44
|
+
logger?.info?.(`[standby-scheduler] ${sessionId}: cooldown alcanzado, disparando resume`);
|
|
45
|
+
try { onResume(sessionId); } catch (err) { logger?.warn?.(`[standby-scheduler] onResume threw: ${err.message}`); }
|
|
46
|
+
}, delay);
|
|
47
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
48
|
+
_scheduled.set(sessionId, timer);
|
|
49
|
+
logger?.info?.(`[standby-scheduler] ${sessionId}: scheduled in ${Math.round(delay / 1000)}s (until ${cooldownUntil})`);
|
|
50
|
+
return { scheduled: true, msUntil: delay };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Cancela el setTimeout de una sesión (caso: usuario hizo kj resume manual). */
|
|
54
|
+
export function cancelScheduled(sessionId) {
|
|
55
|
+
const t = _scheduled.get(sessionId);
|
|
56
|
+
if (!t) return false;
|
|
57
|
+
clearTimeout(t);
|
|
58
|
+
_scheduled.delete(sessionId);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reconciliación al arrancar el board / kj. Lee todas las sesiones pendientes
|
|
64
|
+
* y para cada una:
|
|
65
|
+
* - cooldownUntil < now → onResume inmediato
|
|
66
|
+
* - cooldownUntil > now → re-programa setTimeout
|
|
67
|
+
*
|
|
68
|
+
* Idempotente: el lockfile en standby-store previene dobles spawns si
|
|
69
|
+
* reconcileAll() corre dos veces concurrentes (board reload).
|
|
70
|
+
*
|
|
71
|
+
* @param {object} args
|
|
72
|
+
* @param {Function} args.onResume - callback(sessionId)
|
|
73
|
+
* @param {object} [args.logger]
|
|
74
|
+
* @returns {{ scheduled: number, immediate: number, total: number }}
|
|
75
|
+
*/
|
|
76
|
+
export function reconcileAll({ onResume, logger }) {
|
|
77
|
+
const sessions = listPendingStandby();
|
|
78
|
+
let scheduled = 0;
|
|
79
|
+
let immediate = 0;
|
|
80
|
+
for (const s of sessions) {
|
|
81
|
+
if (!s?.sessionId || !s?.cooldownUntil) continue;
|
|
82
|
+
const r = scheduleResume({ sessionId: s.sessionId, cooldownUntil: s.cooldownUntil, onResume, logger });
|
|
83
|
+
if (r.scheduled && r.immediate) immediate += 1;
|
|
84
|
+
else if (r.scheduled) scheduled += 1;
|
|
85
|
+
}
|
|
86
|
+
logger?.info?.(`[standby-scheduler] reconcile: ${sessions.length} sesiones (${immediate} resume inmediato, ${scheduled} programadas)`);
|
|
87
|
+
return { scheduled, immediate, total: sessions.length };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Re-export para que callers no tengan que importar standby-store si solo
|
|
91
|
+
// necesitan el flow scheduler → lock → done.
|
|
92
|
+
export { acquireStandbyLock, markStandbyDone };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// KJC-TSK-0414 PR 1: persistencia de sesiones hibernadas al disco.
|
|
2
|
+
//
|
|
3
|
+
// Cuando Brain decide hibernar (QUOTA_EXHAUSTED_DAILY con retryAfter > 5min),
|
|
4
|
+
// serializa el estado completo del run en ~/.kj/standby/<sessionId>.json.
|
|
5
|
+
// El proceso de kj run muere (exit 0) tras persistir; el board (o kj resume)
|
|
6
|
+
// lo reanuda cuando llega cooldownUntil.
|
|
7
|
+
//
|
|
8
|
+
// Layout en disco:
|
|
9
|
+
// ~/.kj/standby/
|
|
10
|
+
// <sessionId>.json ← sesiones pendientes (scheduler les mete setTimeout)
|
|
11
|
+
// <sessionId>.lock ← lockfile durante spawn de kj resume
|
|
12
|
+
// done/
|
|
13
|
+
// <sessionId>.json ← finalizadas (consumibles por GC en TSK-0414 PR3)
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
|
|
19
|
+
const FILENAME_RE = /^[a-zA-Z0-9._-]+\.json$/;
|
|
20
|
+
|
|
21
|
+
function getKjHome() {
|
|
22
|
+
if (process.env.KJ_HOME) return process.env.KJ_HOME;
|
|
23
|
+
if (process.env.VITEST) return path.join(os.tmpdir(), `kj-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`, ".kj");
|
|
24
|
+
return path.join(os.homedir(), ".kj");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function standbyDir() {
|
|
28
|
+
return path.join(getKjHome(), "standby");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function standbyDoneDir() {
|
|
32
|
+
return path.join(standbyDir(), "done");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sanitizeId(sessionId) {
|
|
36
|
+
if (!sessionId || typeof sessionId !== "string") throw new Error("sessionId requerido");
|
|
37
|
+
return sessionId.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Persiste el estado completo de un run hibernado.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} state - shape:
|
|
44
|
+
* { sessionId, planId?, huId?, role, iteration?, prompt, agentConfig,
|
|
45
|
+
* retryCount?, cooldownUntil, reason, snapshot_sha?, outcome_so_far? }
|
|
46
|
+
* @returns {string} path absoluto del JSON escrito
|
|
47
|
+
*/
|
|
48
|
+
export function persistStandby(state) {
|
|
49
|
+
if (!state || !state.sessionId) throw new Error("state.sessionId requerido");
|
|
50
|
+
if (!state.cooldownUntil) throw new Error("state.cooldownUntil requerido");
|
|
51
|
+
const dir = standbyDir();
|
|
52
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
53
|
+
const file = path.join(dir, `${sanitizeId(state.sessionId)}.json`);
|
|
54
|
+
const payload = {
|
|
55
|
+
...state,
|
|
56
|
+
createdAt: state.createdAt || new Date().toISOString(),
|
|
57
|
+
persistedAt: new Date().toISOString(),
|
|
58
|
+
};
|
|
59
|
+
fs.writeFileSync(file, JSON.stringify(payload, null, 2), "utf-8");
|
|
60
|
+
return file;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} sessionId
|
|
65
|
+
* @returns {object|null}
|
|
66
|
+
*/
|
|
67
|
+
export function loadStandby(sessionId) {
|
|
68
|
+
const file = path.join(standbyDir(), `${sanitizeId(sessionId)}.json`);
|
|
69
|
+
if (!fs.existsSync(file)) return null;
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Lista todas las sesiones pendientes (no las de done/).
|
|
79
|
+
* @returns {object[]}
|
|
80
|
+
*/
|
|
81
|
+
export function listPendingStandby() {
|
|
82
|
+
const dir = standbyDir();
|
|
83
|
+
if (!fs.existsSync(dir)) return [];
|
|
84
|
+
return fs.readdirSync(dir)
|
|
85
|
+
.filter((f) => FILENAME_RE.test(f))
|
|
86
|
+
.map((f) => {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
.filter(Boolean);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Mueve una sesión a done/ — consumible por GC en TSK-0414 PR3.
|
|
98
|
+
* @param {string} sessionId
|
|
99
|
+
* @returns {boolean} true si se movió
|
|
100
|
+
*/
|
|
101
|
+
export function markStandbyDone(sessionId) {
|
|
102
|
+
const id = sanitizeId(sessionId);
|
|
103
|
+
const src = path.join(standbyDir(), `${id}.json`);
|
|
104
|
+
if (!fs.existsSync(src)) return false;
|
|
105
|
+
const doneDir = standbyDoneDir();
|
|
106
|
+
fs.mkdirSync(doneDir, { recursive: true });
|
|
107
|
+
const dst = path.join(doneDir, `${id}-${Date.now()}.json`);
|
|
108
|
+
fs.renameSync(src, dst);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Lockfile para evitar dobles spawns de kj resume sobre la misma sesión.
|
|
114
|
+
* Devuelve { acquired: true, release: () => void } o { acquired: false } si ya hay lock.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} sessionId
|
|
117
|
+
* @returns {{ acquired: boolean, release?: Function }}
|
|
118
|
+
*/
|
|
119
|
+
export function acquireStandbyLock(sessionId) {
|
|
120
|
+
const id = sanitizeId(sessionId);
|
|
121
|
+
const lockFile = path.join(standbyDir(), `${id}.lock`);
|
|
122
|
+
try {
|
|
123
|
+
fs.mkdirSync(standbyDir(), { recursive: true });
|
|
124
|
+
fs.writeFileSync(lockFile, String(process.pid), { flag: "wx" });
|
|
125
|
+
return { acquired: true, release: () => { try { fs.unlinkSync(lockFile); } catch { /* */ } } };
|
|
126
|
+
} catch {
|
|
127
|
+
return { acquired: false };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// KJC-TSK-0412: wrapper central de recovery. Cualquier stage que invoque
|
|
2
|
+
// un agente IA pasa por aquí. Brain consume classifyAgentError (TSK-0411)
|
|
3
|
+
// y decide: standby corto, backoff exponencial, hibernar, o abortar.
|
|
4
|
+
//
|
|
5
|
+
// Hibernar al disco (acción persistente) es TSK-0414 — esta PR devuelve
|
|
6
|
+
// { ok: false, action: "hibernate", retryUntil } cuando aplica; el caller
|
|
7
|
+
// puede ignorarlo (fallback a long standby capped) hasta que llegue 0414.
|
|
8
|
+
|
|
9
|
+
import { classifyAgentError, ERROR_CLASS } from "./agent-error-classifier.js";
|
|
10
|
+
import { persistStandby } from "./standby-store.js";
|
|
11
|
+
|
|
12
|
+
const ONE_MIN = 60 * 1000;
|
|
13
|
+
|
|
14
|
+
const ONE_HOUR = 60 * 60 * 1000;
|
|
15
|
+
const DEFAULT_FALLBACK_WAIT_HOURS = 12;
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_RECOVERY_POLICY = Object.freeze({
|
|
18
|
+
hibernateThresholdMs: 5 * ONE_MIN, // > 5 min de espera → hibernar
|
|
19
|
+
longStandbyCapMs: 10 * ONE_MIN, // si el caller no hiberna, espera máx 10 min
|
|
20
|
+
// KJC-TSK-0415: threshold de fallback. Si retryAfter > esto Y el role
|
|
21
|
+
// tiene fallback configurado → switch provider en vez de hibernar.
|
|
22
|
+
fallbackWaitHoursDefault: DEFAULT_FALLBACK_WAIT_HOURS,
|
|
23
|
+
classes: {
|
|
24
|
+
[ERROR_CLASS.RATE_LIMIT_SHORT]: { mode: "standby", maxRetries: 3 },
|
|
25
|
+
[ERROR_CLASS.QUOTA_EXHAUSTED_DAILY]: { mode: "hibernate", maxRetries: 1, fallbackEligible: true },
|
|
26
|
+
[ERROR_CLASS.QUOTA_EXHAUSTED_MONTHLY]: { mode: "hibernate", maxRetries: 1, fallbackEligible: true },
|
|
27
|
+
[ERROR_CLASS.API_DOWN]: { mode: "backoff", maxRetries: 3, baseMs: 5_000, factor: 3, jitterPct: 0.2 },
|
|
28
|
+
[ERROR_CLASS.NETWORK_TIMEOUT]: { mode: "backoff", maxRetries: 3, baseMs: 5_000, factor: 3, jitterPct: 0.2 },
|
|
29
|
+
[ERROR_CLASS.SILENCED]: { mode: "backoff", maxRetries: 2, baseMs: 30_000, factor: 2, jitterPct: 0.15 },
|
|
30
|
+
[ERROR_CLASS.AUTH_FAILED]: { mode: "abort", maxRetries: 0 },
|
|
31
|
+
[ERROR_CLASS.UNKNOWN_FATAL]: { mode: "abort", maxRetries: 0 },
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function backoffDelay(policyForClass, attempt) {
|
|
36
|
+
const { baseMs = 5_000, factor = 2, jitterPct = 0.1 } = policyForClass;
|
|
37
|
+
const ms = baseMs * factor ** attempt;
|
|
38
|
+
const jitter = ms * jitterPct * (Math.random() * 2 - 1);
|
|
39
|
+
return Math.max(0, Math.round(ms + jitter));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sleep(ms) {
|
|
43
|
+
// En tests (VITEST / NODE_ENV=test) saltamos sleeps reales — los tests que
|
|
44
|
+
// necesitan timing controlado inyectan sleepFn explícito. Esto evita que
|
|
45
|
+
// tests de stages downstream que llaman a withBrainRecovery se cuelguen
|
|
46
|
+
// esperando standby cuando el agente fake devuelve un error recoverable.
|
|
47
|
+
if (process.env.VITEST || process.env.NODE_ENV === "test") return Promise.resolve();
|
|
48
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function emit(emitter, type, eventBase, detail) {
|
|
52
|
+
if (!emitter || typeof emitter.emit !== "function") return;
|
|
53
|
+
try {
|
|
54
|
+
emitter.emit("progress", { ...(eventBase || {}), type, detail });
|
|
55
|
+
} catch { /* nunca bloquees un retry por un emitter mal */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {object} args
|
|
60
|
+
* @param {object} args.agent - Agent con runTask({...})
|
|
61
|
+
* @param {object} args.taskArgs - args pasados a agent.runTask
|
|
62
|
+
* @param {string} args.role - "planner" | "coder" | "reviewer" | ...
|
|
63
|
+
* @param {string} [args.provider] - claude|codex|gemini|opencode (default: agent.provider)
|
|
64
|
+
* @param {object} [args.policy] - override DEFAULT_RECOVERY_POLICY
|
|
65
|
+
* @param {object} [args.emitter] - bus de eventos
|
|
66
|
+
* @param {object} [args.eventBase]
|
|
67
|
+
* @param {object} [args.logger]
|
|
68
|
+
* @param {Function} [args.sleepFn] - inyectable para tests
|
|
69
|
+
* @returns {Promise<{ ok: boolean, output?: string, action?: string, recovery?: object, error?: string }>}
|
|
70
|
+
*/
|
|
71
|
+
export async function withBrainRecovery({
|
|
72
|
+
agent, taskArgs, role, provider,
|
|
73
|
+
policy = DEFAULT_RECOVERY_POLICY,
|
|
74
|
+
emitter, eventBase, logger,
|
|
75
|
+
sleepFn = sleep,
|
|
76
|
+
// TSK-0414 PR2: si presente, en hibernate persiste { sessionId, planId?, huId?,
|
|
77
|
+
// argv?, ... } a ~/.kj/standby/<sessionId>.json y devuelve standbyFile en
|
|
78
|
+
// el resultado.
|
|
79
|
+
sessionState,
|
|
80
|
+
// KJC-TSK-0415: fallback chain. Si QUOTA_EXHAUSTED_* con retryAfter
|
|
81
|
+
// > maxWaitHours (default 12h) y hay fallback, switch en vez de hibernar.
|
|
82
|
+
fallback = null,
|
|
83
|
+
}) {
|
|
84
|
+
let effectiveAgent = agent;
|
|
85
|
+
let effectiveProvider = provider || agent?.provider || "unknown";
|
|
86
|
+
let effectiveFallback = fallback;
|
|
87
|
+
const attemptsByClass = {};
|
|
88
|
+
|
|
89
|
+
while (true) {
|
|
90
|
+
const result = await effectiveAgent.runTask(taskArgs);
|
|
91
|
+
if (result?.ok) return result;
|
|
92
|
+
|
|
93
|
+
const cls = classifyAgentError({
|
|
94
|
+
provider: effectiveProvider,
|
|
95
|
+
stdout: result?.output || "",
|
|
96
|
+
stderr: result?.error || "",
|
|
97
|
+
exitCode: result?.exitCode ?? null,
|
|
98
|
+
});
|
|
99
|
+
const classPolicy = policy.classes[cls.class] || { mode: "abort", maxRetries: 0 };
|
|
100
|
+
attemptsByClass[cls.class] = (attemptsByClass[cls.class] || 0) + 1;
|
|
101
|
+
const attempt = attemptsByClass[cls.class];
|
|
102
|
+
|
|
103
|
+
emit(emitter, "brain:agent-error", eventBase, { role, class: cls.class, attempt, message: cls.message, provider: effectiveProvider });
|
|
104
|
+
logger?.warn?.(`[brain] ${role} (${effectiveProvider}) → ${cls.class} attempt ${attempt}/${classPolicy.maxRetries}: ${cls.message}`);
|
|
105
|
+
|
|
106
|
+
// ABORT: no recuperable.
|
|
107
|
+
if (classPolicy.mode === "abort" || attempt > classPolicy.maxRetries) {
|
|
108
|
+
emit(emitter, "brain:fatal", eventBase, { role, class: cls.class, message: cls.message });
|
|
109
|
+
return { ok: false, action: "abort", recovery: cls, error: `Brain aborted: ${cls.class} — ${cls.message}` };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// KJC-TSK-0415: ¿switch a fallback antes de hibernar?
|
|
113
|
+
// Aplica si:
|
|
114
|
+
// - classPolicy.fallbackEligible (QUOTA_EXHAUSTED_*)
|
|
115
|
+
// - hay fallback configurado
|
|
116
|
+
// - retryAfter excede maxWaitHours del fallback (default 12h)
|
|
117
|
+
if (classPolicy.fallbackEligible && effectiveFallback?.agent && cls.retryAfter) {
|
|
118
|
+
const maxWaitMs = (effectiveFallback.maxWaitHours ?? policy.fallbackWaitHoursDefault ?? DEFAULT_FALLBACK_WAIT_HOURS) * ONE_HOUR;
|
|
119
|
+
if (cls.retryAfter > maxWaitMs) {
|
|
120
|
+
const from = effectiveProvider;
|
|
121
|
+
const to = effectiveFallback.provider || effectiveFallback.agent.provider || "unknown";
|
|
122
|
+
emit(emitter, "brain:fallback-switched", eventBase, { role, from, to, class: cls.class, retryAfter: cls.retryAfter, maxWaitMs });
|
|
123
|
+
logger?.info?.(`[brain] ${role}: fallback ${from} → ${to} (retryAfter ${Math.round(cls.retryAfter/3600000)}h > maxWait ${maxWaitMs/3600000}h)`);
|
|
124
|
+
// Switch al fallback. Reset attempts para que el nuevo provider
|
|
125
|
+
// pueda fallar/retry independientemente. Fallback puede tener su
|
|
126
|
+
// propio fallback en cadena.
|
|
127
|
+
effectiveAgent = effectiveFallback.agent;
|
|
128
|
+
effectiveProvider = to;
|
|
129
|
+
effectiveFallback = effectiveFallback.fallback || null;
|
|
130
|
+
for (const k of Object.keys(attemptsByClass)) attemptsByClass[k] = 0;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// HIBERNATE: persistir + signal action=hibernate al caller.
|
|
136
|
+
// Si el caller pasó `sessionState` (TSK-0414 PR2), persistimos el JSON
|
|
137
|
+
// automáticamente en ~/.kj/standby/<sessionId>.json. Caller decide qué
|
|
138
|
+
// hacer con action=hibernate (típicamente exit 0 para liberar memoria).
|
|
139
|
+
if (classPolicy.mode === "hibernate" && cls.retryAfter && cls.retryAfter > policy.hibernateThresholdMs) {
|
|
140
|
+
let standbyFile = null;
|
|
141
|
+
if (sessionState && sessionState.sessionId) {
|
|
142
|
+
try {
|
|
143
|
+
standbyFile = persistStandby({
|
|
144
|
+
...sessionState,
|
|
145
|
+
cooldownUntil: cls.retryUntil,
|
|
146
|
+
reason: cls.class,
|
|
147
|
+
role, provider: effectiveProvider,
|
|
148
|
+
classifierMessage: cls.message,
|
|
149
|
+
});
|
|
150
|
+
logger?.info?.(`[brain] hibernated session ${sessionState.sessionId} → ${standbyFile} (resume at ${cls.retryUntil})`);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
logger?.warn?.(`[brain] failed to persist standby: ${err.message}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
emit(emitter, "brain:hibernate-request", eventBase, { role, class: cls.class, retryUntil: cls.retryUntil, retryAfter: cls.retryAfter, standbyFile });
|
|
156
|
+
return { ok: false, action: "hibernate", recovery: cls, standbyFile };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// STANDBY: espera hasta cooldownUntil (capado por longStandbyCapMs).
|
|
160
|
+
if (classPolicy.mode === "standby") {
|
|
161
|
+
const waitMs = Math.min(cls.retryAfter || ONE_MIN, policy.longStandbyCapMs);
|
|
162
|
+
emit(emitter, "brain:standby", eventBase, { role, class: cls.class, waitMs, retryUntil: cls.retryUntil });
|
|
163
|
+
await sleepFn(waitMs);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// BACKOFF exponencial con jitter.
|
|
168
|
+
if (classPolicy.mode === "backoff") {
|
|
169
|
+
const waitMs = backoffDelay(classPolicy, attempt - 1);
|
|
170
|
+
emit(emitter, "brain:backoff", eventBase, { role, class: cls.class, attempt, waitMs });
|
|
171
|
+
await sleepFn(waitMs);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Modo desconocido — defensive abort.
|
|
176
|
+
return { ok: false, action: "abort", recovery: cls, error: `Brain: unknown mode '${classPolicy.mode}' for class ${cls.class}` };
|
|
177
|
+
}
|
|
178
|
+
}
|
package/src/cli/register-plan.js
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
planDeleteCommand,
|
|
8
8
|
planAddHuCommand,
|
|
9
9
|
planRemoveHuCommand,
|
|
10
|
+
planMigrateResultCommand,
|
|
11
|
+
planFixCommand,
|
|
10
12
|
} from "../commands/plan.js";
|
|
11
13
|
import { parseCanvasMarkdown } from "../canvas/parse-md.js";
|
|
12
14
|
import { validateCanvas } from "../canvas/validate.js";
|
|
@@ -149,4 +151,31 @@ export function registerPlan(program, { pkgVersion }) {
|
|
|
149
151
|
await planRemoveHuCommand({ config, planId, huId });
|
|
150
152
|
});
|
|
151
153
|
});
|
|
154
|
+
|
|
155
|
+
// KJC-TSK-0402: re-ejecuta reviewer + self-fix + structural pass sobre
|
|
156
|
+
// un plan existente. Acepta --prompt opcional con feedback humano que
|
|
157
|
+
// el reviewer inyecta como contexto extra antes de revisar.
|
|
158
|
+
plan.command("fix")
|
|
159
|
+
.description("Relanzar self-fix loop sobre plan existente (con --prompt opcional)")
|
|
160
|
+
.argument("<planId>")
|
|
161
|
+
.option("--prompt <text>", "Feedback del usuario para guiar al reviewer")
|
|
162
|
+
.option("--json", "Output JSON con before/after counts")
|
|
163
|
+
.action(async (planId, flags) => {
|
|
164
|
+
await withConfig(pkgVersion, "plan", flags, async ({ config, logger }) => {
|
|
165
|
+
await planFixCommand({ config, planId, prompt: flags.prompt, logger, json: flags.json });
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// KJC-TSK-0394 step 4: migración one-shot que siembra `result` en las
|
|
170
|
+
// HUs de los plan files existentes (basado en su status legacy). Solo
|
|
171
|
+
// toca el campo result, NO el status — el cleanup del enum lo hará
|
|
172
|
+
// step 5. Idempotente.
|
|
173
|
+
plan.command("migrate-result")
|
|
174
|
+
.description("Backfill the `result` field on existing plan HUs (KJC-TSK-0394 step 4)")
|
|
175
|
+
.option("--dry-run", "Show what would change without writing")
|
|
176
|
+
.action(async (flags) => {
|
|
177
|
+
await withConfig(pkgVersion, "plan", flags, async ({ config }) => {
|
|
178
|
+
await planMigrateResultCommand({ config, flags: { dryRun: flags.dryRun } });
|
|
179
|
+
});
|
|
180
|
+
});
|
|
152
181
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// KJC-TSK-0414 PR2: registra `kj standby list` + `kj standby resume <id>`.
|
|
2
|
+
// `resume` es subcomando de standby para no colisionar con el `kj resume`
|
|
3
|
+
// pre-existente (Solomon pauses con --answer). Modelos mentales distintos:
|
|
4
|
+
// - `kj resume <id> --answer X` → Solomon pauseado (existente)
|
|
5
|
+
// - `kj standby resume <id>` → run hibernada por quota
|
|
6
|
+
import { resumeCommand, standbyListCommand } from "../commands/standby.js";
|
|
7
|
+
|
|
8
|
+
export function registerStandby(program) {
|
|
9
|
+
const standby = program.command("standby").description("Sesiones hibernadas (QUOTA_EXHAUSTED_DAILY etc.)");
|
|
10
|
+
standby
|
|
11
|
+
.command("list", { isDefault: true })
|
|
12
|
+
.description("Lista sesiones pendientes de reanudar")
|
|
13
|
+
.option("--json", "Output JSON")
|
|
14
|
+
.action(async (flags) => {
|
|
15
|
+
await standbyListCommand({ json: flags.json });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
standby
|
|
19
|
+
.command("resume")
|
|
20
|
+
.description("Reanuda una sesión hibernada (re-spawn comando original)")
|
|
21
|
+
.argument("<sessionId>")
|
|
22
|
+
.option("--force", "Reanudar aunque cooldownUntil todavía esté en el futuro (no recomendado)")
|
|
23
|
+
.action(async (sessionId, flags) => {
|
|
24
|
+
await resumeCommand({ sessionId, force: Boolean(flags.force) });
|
|
25
|
+
});
|
|
26
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { registerPlan } from "./cli/register-plan.js";
|
|
|
9
9
|
import { registerRolesSkills } from "./cli/register-roles-skills.js";
|
|
10
10
|
import { registerMeta } from "./cli/register-meta.js";
|
|
11
11
|
import { registerSonar } from "./cli/register-sonar.js";
|
|
12
|
+
import { registerStandby } from "./cli/register-standby.js";
|
|
12
13
|
import { printUpdateNotice } from "./utils/update-check.js";
|
|
13
14
|
import { printWelcomeScreen } from "./utils/welcome.js";
|
|
14
15
|
|
|
@@ -44,6 +45,7 @@ registerPlan(program, { pkgVersion: PKG_VERSION });
|
|
|
44
45
|
registerRolesSkills(program, { pkgVersion: PKG_VERSION });
|
|
45
46
|
registerMeta(program, { pkgVersion: PKG_VERSION });
|
|
46
47
|
registerSonar(program);
|
|
48
|
+
registerStandby(program);
|
|
47
49
|
|
|
48
50
|
/**
|
|
49
51
|
* Find subcommands whose name is "close enough" to a typo. Used by the
|
|
@@ -3,6 +3,7 @@ import { assertAgentsAvailable } from "../agents/availability.js";
|
|
|
3
3
|
import { resolveRole } from "../config.js";
|
|
4
4
|
import { buildArchitectPrompt, parseArchitectOutput } from "../prompts/architect.js";
|
|
5
5
|
import { createCliProgressReporter } from "../utils/cli-progress.js";
|
|
6
|
+
import { withBrainRecovery } from "../brain/with-brain-recovery.js";
|
|
6
7
|
|
|
7
8
|
function formatLayers(layers, lines) {
|
|
8
9
|
lines.push("### Layers");
|
|
@@ -71,8 +72,11 @@ export async function architectCommand({ task, config, logger, context, json })
|
|
|
71
72
|
const progress = createCliProgressReporter({ role: "architect" });
|
|
72
73
|
let result;
|
|
73
74
|
try {
|
|
74
|
-
result = await
|
|
75
|
-
|
|
75
|
+
result = await withBrainRecovery({
|
|
76
|
+
agent, taskArgs: { prompt, onOutput: progress.onOutput, role: "architect" },
|
|
77
|
+
role: "architect", provider: architectRole.provider, logger,
|
|
78
|
+
});
|
|
79
|
+
progress.finish(result.ok ? "done" : (result.action || "failed"));
|
|
76
80
|
} catch (err) { progress.finish("failed"); throw err; }
|
|
77
81
|
|
|
78
82
|
if (!result.ok) {
|
package/src/commands/discover.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createAgent } from "../agents/index.js";
|
|
2
2
|
import { assertAgentsAvailable } from "../agents/availability.js";
|
|
3
3
|
import { resolveRole } from "../config.js";
|
|
4
|
+
import { withBrainRecovery } from "../brain/with-brain-recovery.js";
|
|
4
5
|
import { createCliProgressReporter } from "../utils/cli-progress.js";
|
|
5
6
|
import { buildDiscoverPrompt, parseDiscoverOutput } from "../prompts/discover.js";
|
|
6
7
|
|
|
@@ -75,8 +76,11 @@ export async function discoverCommand({ task, config, logger, mode, json }) {
|
|
|
75
76
|
const progress = createCliProgressReporter({ role: "discover" });
|
|
76
77
|
let result;
|
|
77
78
|
try {
|
|
78
|
-
result = await
|
|
79
|
-
|
|
79
|
+
result = await withBrainRecovery({
|
|
80
|
+
agent, taskArgs: { prompt, onOutput: progress.onOutput, role: "discover" },
|
|
81
|
+
role: "discover", provider: discoverRole.provider, logger,
|
|
82
|
+
});
|
|
83
|
+
progress.finish(result.ok ? "done" : (result.action || "failed"));
|
|
80
84
|
} catch (err) { progress.finish("failed"); throw err; }
|
|
81
85
|
|
|
82
86
|
if (!result.ok) {
|