karajan-code 1.8.0 → 1.9.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/package.json +1 -1
- package/src/agents/claude-agent.js +79 -1
- package/src/mcp/progress.js +16 -2
- package/src/mcp/server-handlers.js +117 -5
- package/src/mcp/tools.js +10 -0
- package/src/orchestrator/iteration-stages.js +51 -19
- package/src/orchestrator/pre-loop-stages.js +51 -3
- package/src/roles/planner-role.js +8 -3
- package/src/roles/refactorer-role.js +4 -1
- package/src/roles/researcher-role.js +4 -1
- package/src/roles/triage-role.js +4 -1
- package/src/utils/run-log.js +126 -0
- package/src/utils/stall-detector.js +126 -0
package/package.json
CHANGED
|
@@ -2,13 +2,91 @@ import { BaseAgent } from "./base-agent.js";
|
|
|
2
2
|
import { runCommand } from "../utils/process.js";
|
|
3
3
|
import { resolveBin } from "./resolve-bin.js";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Extract the final text result from stream-json NDJSON output.
|
|
7
|
+
* Each line is a JSON object. We collect assistant text content from
|
|
8
|
+
* "result" messages and fall back to accumulating "content_block_delta" text.
|
|
9
|
+
*/
|
|
10
|
+
function extractTextFromStreamJson(raw) {
|
|
11
|
+
const lines = (raw || "").split("\n").filter(Boolean);
|
|
12
|
+
// Try to find a "result" message with the final text
|
|
13
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
14
|
+
try {
|
|
15
|
+
const obj = JSON.parse(lines[i]);
|
|
16
|
+
if (obj.type === "result" && obj.result) {
|
|
17
|
+
return typeof obj.result === "string" ? obj.result : JSON.stringify(obj.result);
|
|
18
|
+
}
|
|
19
|
+
// Claude Code stream-json final message
|
|
20
|
+
if (obj.result && typeof obj.result === "string") {
|
|
21
|
+
return obj.result;
|
|
22
|
+
}
|
|
23
|
+
} catch { /* skip unparseable lines */ }
|
|
24
|
+
}
|
|
25
|
+
// Fallback: accumulate all assistant text deltas
|
|
26
|
+
const parts = [];
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
try {
|
|
29
|
+
const obj = JSON.parse(line);
|
|
30
|
+
if (obj.type === "assistant" && obj.message?.content) {
|
|
31
|
+
for (const block of obj.message.content) {
|
|
32
|
+
if (block.type === "text" && block.text) parts.push(block.text);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch { /* skip */ }
|
|
36
|
+
}
|
|
37
|
+
return parts.join("") || raw;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a wrapping onOutput that parses stream-json lines and forwards
|
|
42
|
+
* meaningful content (assistant text, tool usage) to the original callback.
|
|
43
|
+
*/
|
|
44
|
+
function createStreamJsonFilter(onOutput) {
|
|
45
|
+
if (!onOutput) return null;
|
|
46
|
+
return ({ stream, line }) => {
|
|
47
|
+
try {
|
|
48
|
+
const obj = JSON.parse(line);
|
|
49
|
+
// Forward assistant text messages
|
|
50
|
+
if (obj.type === "assistant" && obj.message?.content) {
|
|
51
|
+
for (const block of obj.message.content) {
|
|
52
|
+
if (block.type === "text" && block.text) {
|
|
53
|
+
onOutput({ stream, line: block.text.slice(0, 200) });
|
|
54
|
+
} else if (block.type === "tool_use") {
|
|
55
|
+
onOutput({ stream, line: `[tool: ${block.name}]` });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Forward result
|
|
61
|
+
if (obj.type === "result") {
|
|
62
|
+
const summary = typeof obj.result === "string"
|
|
63
|
+
? obj.result.slice(0, 200)
|
|
64
|
+
: "result received";
|
|
65
|
+
onOutput({ stream, line: `[result] ${summary}` });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
} catch { /* not JSON, forward raw */ }
|
|
69
|
+
onOutput({ stream, line });
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
5
73
|
export class ClaudeAgent extends BaseAgent {
|
|
6
74
|
async runTask(task) {
|
|
7
75
|
const role = task.role || "coder";
|
|
8
76
|
const args = ["-p", task.prompt];
|
|
9
77
|
const model = this.getRoleModel(role);
|
|
10
78
|
if (model) args.push("--model", model);
|
|
11
|
-
|
|
79
|
+
|
|
80
|
+
// Use stream-json when onOutput is provided to get real-time feedback
|
|
81
|
+
if (task.onOutput) {
|
|
82
|
+
args.push("--output-format", "stream-json");
|
|
83
|
+
const streamFilter = createStreamJsonFilter(task.onOutput);
|
|
84
|
+
const res = await runCommand(resolveBin("claude"), args, { onOutput: streamFilter });
|
|
85
|
+
const output = extractTextFromStreamJson(res.stdout);
|
|
86
|
+
return { ok: res.exitCode === 0, output, error: res.stderr, exitCode: res.exitCode };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const res = await runCommand(resolveBin("claude"), args);
|
|
12
90
|
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
|
|
13
91
|
}
|
|
14
92
|
|
package/src/mcp/progress.js
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
export const PROGRESS_STAGES = [
|
|
7
7
|
"session:start",
|
|
8
8
|
"iteration:start",
|
|
9
|
+
"triage:start",
|
|
10
|
+
"triage:end",
|
|
11
|
+
"researcher:start",
|
|
12
|
+
"researcher:end",
|
|
9
13
|
"planner:start",
|
|
10
14
|
"planner:end",
|
|
11
15
|
"coder:start",
|
|
@@ -22,7 +26,9 @@ export const PROGRESS_STAGES = [
|
|
|
22
26
|
"question",
|
|
23
27
|
"session:end",
|
|
24
28
|
"dry-run:summary",
|
|
25
|
-
"pipeline:tracker"
|
|
29
|
+
"pipeline:tracker",
|
|
30
|
+
"agent:heartbeat",
|
|
31
|
+
"agent:stall"
|
|
26
32
|
];
|
|
27
33
|
|
|
28
34
|
const PIPELINE_ORDER = [
|
|
@@ -89,11 +95,19 @@ export function sendTrackerLog(server, stageName, status, summary) {
|
|
|
89
95
|
}
|
|
90
96
|
}
|
|
91
97
|
|
|
98
|
+
function resolveLogLevel(event) {
|
|
99
|
+
if (event.type === "agent:output") return "debug";
|
|
100
|
+
if (event.type === "agent:heartbeat") return "debug";
|
|
101
|
+
if (event.type === "agent:stall") return "warning";
|
|
102
|
+
if (event.status === "fail") return "error";
|
|
103
|
+
return "info";
|
|
104
|
+
}
|
|
105
|
+
|
|
92
106
|
export function buildProgressHandler(server) {
|
|
93
107
|
return (event) => {
|
|
94
108
|
try {
|
|
95
109
|
server.sendLoggingMessage({
|
|
96
|
-
level: event
|
|
110
|
+
level: resolveLogLevel(event),
|
|
97
111
|
logger: "karajan",
|
|
98
112
|
data: event
|
|
99
113
|
});
|
|
@@ -8,6 +8,7 @@ import fs from "node:fs/promises";
|
|
|
8
8
|
import { runKjCommand } from "./run-kj.js";
|
|
9
9
|
import { normalizePlanArgs } from "./tool-arg-normalizers.js";
|
|
10
10
|
import { buildProgressHandler, buildProgressNotifier, buildPipelineTracker, sendTrackerLog } from "./progress.js";
|
|
11
|
+
import { createStallDetector } from "../utils/stall-detector.js";
|
|
11
12
|
import { runFlow, resumeFlow } from "../orchestrator.js";
|
|
12
13
|
import { loadConfig, applyRunOverrides, validateConfig, resolveRole } from "../config.js";
|
|
13
14
|
import { createLogger } from "../utils/logger.js";
|
|
@@ -19,6 +20,24 @@ import { buildReviewerPrompt } from "../prompts/reviewer.js";
|
|
|
19
20
|
import { parseMaybeJsonString } from "../review/parser.js";
|
|
20
21
|
import { computeBaseRef, generateDiff } from "../review/diff-generator.js";
|
|
21
22
|
import { resolveReviewProfile } from "../review/profiles.js";
|
|
23
|
+
import { createRunLog, readRunLog } from "../utils/run-log.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the user's project directory via MCP roots.
|
|
27
|
+
* Falls back to process.cwd() if roots are not available.
|
|
28
|
+
*/
|
|
29
|
+
async function resolveProjectDir(server) {
|
|
30
|
+
try {
|
|
31
|
+
const { roots } = await server.listRoots();
|
|
32
|
+
if (roots?.length > 0) {
|
|
33
|
+
const uri = roots[0].uri;
|
|
34
|
+
// MCP roots use file:// URIs
|
|
35
|
+
if (uri.startsWith("file://")) return new URL(uri).pathname;
|
|
36
|
+
return uri;
|
|
37
|
+
}
|
|
38
|
+
} catch { /* client may not support roots */ }
|
|
39
|
+
return process.cwd();
|
|
40
|
+
}
|
|
22
41
|
|
|
23
42
|
export function asObject(value) {
|
|
24
43
|
if (value && typeof value === "object") return value;
|
|
@@ -147,8 +166,13 @@ export async function handleRunDirect(a, server, extra) {
|
|
|
147
166
|
if (config.pipeline?.security?.enabled) requiredProviders.push(resolveRole(config, "security").provider);
|
|
148
167
|
await assertAgentsAvailable(requiredProviders);
|
|
149
168
|
|
|
169
|
+
const projectDir = await resolveProjectDir(server);
|
|
170
|
+
const runLog = createRunLog(projectDir);
|
|
171
|
+
runLog.logText(`[kj_run] started — task="${a.task.slice(0, 80)}..."`);
|
|
172
|
+
|
|
150
173
|
const emitter = new EventEmitter();
|
|
151
174
|
emitter.on("progress", buildProgressHandler(server));
|
|
175
|
+
emitter.on("progress", (event) => runLog.logEvent(event));
|
|
152
176
|
const progressNotifier = buildProgressNotifier(extra);
|
|
153
177
|
if (progressNotifier) emitter.on("progress", progressNotifier);
|
|
154
178
|
buildPipelineTracker(config, emitter);
|
|
@@ -156,8 +180,13 @@ export async function handleRunDirect(a, server, extra) {
|
|
|
156
180
|
const askQuestion = buildAskQuestion(server);
|
|
157
181
|
const pgTaskId = a.pgTask || null;
|
|
158
182
|
const pgProject = a.pgProject || config.planning_game?.project_id || null;
|
|
159
|
-
|
|
160
|
-
|
|
183
|
+
try {
|
|
184
|
+
const result = await runFlow({ task: a.task, config, logger, flags: a, emitter, askQuestion, pgTaskId, pgProject });
|
|
185
|
+
runLog.logText(`[kj_run] finished — ok=${!result.paused && (result.approved !== false)}`);
|
|
186
|
+
return { ok: !result.paused && (result.approved !== false), ...result };
|
|
187
|
+
} finally {
|
|
188
|
+
runLog.close();
|
|
189
|
+
}
|
|
161
190
|
}
|
|
162
191
|
|
|
163
192
|
export async function handleResumeDirect(a, server, extra) {
|
|
@@ -182,6 +211,20 @@ export async function handleResumeDirect(a, server, extra) {
|
|
|
182
211
|
return { ok: true, ...result };
|
|
183
212
|
}
|
|
184
213
|
|
|
214
|
+
function buildDirectEmitter(server, runLog) {
|
|
215
|
+
const emitter = new EventEmitter();
|
|
216
|
+
emitter.on("progress", (event) => {
|
|
217
|
+
try {
|
|
218
|
+
const level = event.type === "agent:stall" ? "warning"
|
|
219
|
+
: event.type === "agent:heartbeat" ? "info"
|
|
220
|
+
: "debug";
|
|
221
|
+
server.sendLoggingMessage({ level, logger: "karajan", data: event });
|
|
222
|
+
} catch { /* best-effort */ }
|
|
223
|
+
if (runLog) runLog.logEvent(event);
|
|
224
|
+
});
|
|
225
|
+
return emitter;
|
|
226
|
+
}
|
|
227
|
+
|
|
185
228
|
export async function handlePlanDirect(a, server, extra) {
|
|
186
229
|
const options = normalizePlanArgs(a);
|
|
187
230
|
const config = await buildConfig(options, "plan");
|
|
@@ -190,10 +233,31 @@ export async function handlePlanDirect(a, server, extra) {
|
|
|
190
233
|
const plannerRole = resolveRole(config, "planner");
|
|
191
234
|
await assertAgentsAvailable([plannerRole.provider]);
|
|
192
235
|
|
|
236
|
+
const projectDir = await resolveProjectDir(server);
|
|
237
|
+
const runLog = createRunLog(projectDir);
|
|
238
|
+
runLog.logText(`[kj_plan] started — provider=${plannerRole.provider}`);
|
|
239
|
+
const emitter = buildDirectEmitter(server, runLog);
|
|
240
|
+
const eventBase = { sessionId: null, iteration: 0, startedAt: Date.now() };
|
|
241
|
+
const onOutput = ({ stream, line }) => {
|
|
242
|
+
emitter.emit("progress", { type: "agent:output", stage: "planner", message: line, detail: { stream, agent: plannerRole.provider } });
|
|
243
|
+
};
|
|
244
|
+
const stallDetector = createStallDetector({
|
|
245
|
+
onOutput, emitter, eventBase, stage: "planner", provider: plannerRole.provider
|
|
246
|
+
});
|
|
247
|
+
|
|
193
248
|
const planner = createAgent(plannerRole.provider, config, logger);
|
|
194
249
|
const prompt = buildPlannerPrompt({ task: a.task, context: a.context });
|
|
195
250
|
sendTrackerLog(server, "planner", "running", plannerRole.provider);
|
|
196
|
-
|
|
251
|
+
runLog.logText(`[planner] agent launched, waiting for response...`);
|
|
252
|
+
let result;
|
|
253
|
+
try {
|
|
254
|
+
result = await planner.runTask({ prompt, role: "planner", onOutput: stallDetector.onOutput });
|
|
255
|
+
} finally {
|
|
256
|
+
stallDetector.stop();
|
|
257
|
+
const stats = stallDetector.stats();
|
|
258
|
+
runLog.logText(`[planner] finished — lines=${stats.lineCount}, bytes=${stats.bytesReceived}, elapsed=${Math.round(stats.elapsedMs / 1000)}s`);
|
|
259
|
+
runLog.close();
|
|
260
|
+
}
|
|
197
261
|
|
|
198
262
|
if (!result.ok) {
|
|
199
263
|
sendTrackerLog(server, "planner", "failed");
|
|
@@ -212,6 +276,18 @@ export async function handleCodeDirect(a, server, extra) {
|
|
|
212
276
|
const coderRole = resolveRole(config, "coder");
|
|
213
277
|
await assertAgentsAvailable([coderRole.provider]);
|
|
214
278
|
|
|
279
|
+
const projectDir = await resolveProjectDir(server);
|
|
280
|
+
const runLog = createRunLog(projectDir);
|
|
281
|
+
runLog.logText(`[kj_code] started — provider=${coderRole.provider}`);
|
|
282
|
+
const emitter = buildDirectEmitter(server, runLog);
|
|
283
|
+
const eventBase = { sessionId: null, iteration: 0, startedAt: Date.now() };
|
|
284
|
+
const onOutput = ({ stream, line }) => {
|
|
285
|
+
emitter.emit("progress", { type: "agent:output", stage: "coder", message: line, detail: { stream, agent: coderRole.provider } });
|
|
286
|
+
};
|
|
287
|
+
const stallDetector = createStallDetector({
|
|
288
|
+
onOutput, emitter, eventBase, stage: "coder", provider: coderRole.provider
|
|
289
|
+
});
|
|
290
|
+
|
|
215
291
|
const coder = createAgent(coderRole.provider, config, logger);
|
|
216
292
|
let coderRules = null;
|
|
217
293
|
if (config.coder_rules) {
|
|
@@ -221,7 +297,16 @@ export async function handleCodeDirect(a, server, extra) {
|
|
|
221
297
|
}
|
|
222
298
|
const prompt = buildCoderPrompt({ task: a.task, coderRules, methodology: config.development?.methodology || "tdd" });
|
|
223
299
|
sendTrackerLog(server, "coder", "running", coderRole.provider);
|
|
224
|
-
|
|
300
|
+
runLog.logText(`[coder] agent launched, waiting for response...`);
|
|
301
|
+
let result;
|
|
302
|
+
try {
|
|
303
|
+
result = await coder.runTask({ prompt, role: "coder", onOutput: stallDetector.onOutput });
|
|
304
|
+
} finally {
|
|
305
|
+
stallDetector.stop();
|
|
306
|
+
const stats = stallDetector.stats();
|
|
307
|
+
runLog.logText(`[coder] finished — lines=${stats.lineCount}, bytes=${stats.bytesReceived}, elapsed=${Math.round(stats.elapsedMs / 1000)}s`);
|
|
308
|
+
runLog.close();
|
|
309
|
+
}
|
|
225
310
|
|
|
226
311
|
if (!result.ok) {
|
|
227
312
|
sendTrackerLog(server, "coder", "failed");
|
|
@@ -239,6 +324,18 @@ export async function handleReviewDirect(a, server, extra) {
|
|
|
239
324
|
const reviewerRole = resolveRole(config, "reviewer");
|
|
240
325
|
await assertAgentsAvailable([reviewerRole.provider, config.reviewer_options?.fallback_reviewer]);
|
|
241
326
|
|
|
327
|
+
const projectDir = await resolveProjectDir(server);
|
|
328
|
+
const runLog = createRunLog(projectDir);
|
|
329
|
+
runLog.logText(`[kj_review] started — provider=${reviewerRole.provider}`);
|
|
330
|
+
const emitter = buildDirectEmitter(server, runLog);
|
|
331
|
+
const eventBase = { sessionId: null, iteration: 0, startedAt: Date.now() };
|
|
332
|
+
const onOutput = ({ stream, line }) => {
|
|
333
|
+
emitter.emit("progress", { type: "agent:output", stage: "reviewer", message: line, detail: { stream, agent: reviewerRole.provider } });
|
|
334
|
+
};
|
|
335
|
+
const stallDetector = createStallDetector({
|
|
336
|
+
onOutput, emitter, eventBase, stage: "reviewer", provider: reviewerRole.provider
|
|
337
|
+
});
|
|
338
|
+
|
|
242
339
|
const reviewer = createAgent(reviewerRole.provider, config, logger);
|
|
243
340
|
const resolvedBase = await computeBaseRef({ baseBranch: config.base_branch, baseRef: a.baseRef });
|
|
244
341
|
const diff = await generateDiff({ baseRef: resolvedBase });
|
|
@@ -246,7 +343,16 @@ export async function handleReviewDirect(a, server, extra) {
|
|
|
246
343
|
|
|
247
344
|
const prompt = buildReviewerPrompt({ task: a.task, diff, reviewRules: rules, mode: config.review_mode });
|
|
248
345
|
sendTrackerLog(server, "reviewer", "running", reviewerRole.provider);
|
|
249
|
-
|
|
346
|
+
runLog.logText(`[reviewer] agent launched, waiting for response...`);
|
|
347
|
+
let result;
|
|
348
|
+
try {
|
|
349
|
+
result = await reviewer.reviewTask({ prompt, role: "reviewer", onOutput: stallDetector.onOutput });
|
|
350
|
+
} finally {
|
|
351
|
+
stallDetector.stop();
|
|
352
|
+
const stats = stallDetector.stats();
|
|
353
|
+
runLog.logText(`[reviewer] finished — lines=${stats.lineCount}, bytes=${stats.bytesReceived}, elapsed=${Math.round(stats.elapsedMs / 1000)}s`);
|
|
354
|
+
runLog.close();
|
|
355
|
+
}
|
|
250
356
|
|
|
251
357
|
if (!result.ok) {
|
|
252
358
|
sendTrackerLog(server, "reviewer", "failed");
|
|
@@ -261,6 +367,12 @@ export async function handleReviewDirect(a, server, extra) {
|
|
|
261
367
|
export async function handleToolCall(name, args, server, extra) {
|
|
262
368
|
const a = asObject(args);
|
|
263
369
|
|
|
370
|
+
if (name === "kj_status") {
|
|
371
|
+
const maxLines = a.lines || 50;
|
|
372
|
+
const projectDir = await resolveProjectDir(server);
|
|
373
|
+
return readRunLog(maxLines, projectDir);
|
|
374
|
+
}
|
|
375
|
+
|
|
264
376
|
if (name === "kj_init") {
|
|
265
377
|
return runKjCommand({ command: "init", options: a });
|
|
266
378
|
}
|
package/src/mcp/tools.js
CHANGED
|
@@ -165,6 +165,16 @@ export const tools = [
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
},
|
|
168
|
+
{
|
|
169
|
+
name: "kj_status",
|
|
170
|
+
description: "Show real-time log of the current or last Karajan run. Use this to monitor progress while kj_run/kj_plan/kj_code is executing. Reads from .kj/run.log in the project directory.",
|
|
171
|
+
inputSchema: {
|
|
172
|
+
type: "object",
|
|
173
|
+
properties: {
|
|
174
|
+
lines: { type: "number", description: "Number of log lines to show (default 50)" }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
},
|
|
168
178
|
{
|
|
169
179
|
name: "kj_plan",
|
|
170
180
|
description: "Generate implementation plan for a task",
|
|
@@ -11,6 +11,7 @@ import { runReviewerWithFallback } from "./reviewer-fallback.js";
|
|
|
11
11
|
import { runCoderWithFallback } from "./agent-fallback.js";
|
|
12
12
|
import { invokeSolomon } from "./solomon-escalation.js";
|
|
13
13
|
import { detectRateLimit } from "../utils/rate-limit-detector.js";
|
|
14
|
+
import { createStallDetector } from "../utils/stall-detector.js";
|
|
14
15
|
|
|
15
16
|
export async function runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration }) {
|
|
16
17
|
logger.setContext({ iteration, stage: "coder" });
|
|
@@ -28,13 +29,21 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
|
|
|
28
29
|
detail: { stream, agent: coderRole.provider }
|
|
29
30
|
}));
|
|
30
31
|
};
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
task: plannedTask,
|
|
34
|
-
reviewerFeedback: session.last_reviewer_feedback,
|
|
35
|
-
sonarSummary: session.last_sonar_summary,
|
|
36
|
-
onOutput: coderOnOutput
|
|
32
|
+
const coderStall = createStallDetector({
|
|
33
|
+
onOutput: coderOnOutput, emitter, eventBase, stage: "coder", provider: coderRole.provider
|
|
37
34
|
});
|
|
35
|
+
const coderStart = Date.now();
|
|
36
|
+
let coderExecResult;
|
|
37
|
+
try {
|
|
38
|
+
coderExecResult = await coderRoleInstance.execute({
|
|
39
|
+
task: plannedTask,
|
|
40
|
+
reviewerFeedback: session.last_reviewer_feedback,
|
|
41
|
+
sonarSummary: session.last_sonar_summary,
|
|
42
|
+
onOutput: coderStall.onOutput
|
|
43
|
+
});
|
|
44
|
+
} finally {
|
|
45
|
+
coderStall.stop();
|
|
46
|
+
}
|
|
38
47
|
trackBudget({ role: "coder", provider: coderRole.provider, model: coderRole.model, result: coderExecResult.result, duration_ms: Date.now() - coderStart });
|
|
39
48
|
|
|
40
49
|
if (!coderExecResult.ok) {
|
|
@@ -130,10 +139,25 @@ export async function runRefactorerStage({ refactorerRole, config, logger, emitt
|
|
|
130
139
|
detail: { refactorer: refactorerRole.provider }
|
|
131
140
|
})
|
|
132
141
|
);
|
|
142
|
+
const refactorerOnOutput = ({ stream, line }) => {
|
|
143
|
+
emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "refactorer" }, {
|
|
144
|
+
message: line,
|
|
145
|
+
detail: { stream, agent: refactorerRole.provider }
|
|
146
|
+
}));
|
|
147
|
+
};
|
|
148
|
+
const refactorerStall = createStallDetector({
|
|
149
|
+
onOutput: refactorerOnOutput, emitter, eventBase, stage: "refactorer", provider: refactorerRole.provider
|
|
150
|
+
});
|
|
151
|
+
|
|
133
152
|
const refRole = new RefactorerRole({ config, logger, emitter, createAgentFn: createAgent });
|
|
134
153
|
await refRole.init();
|
|
135
154
|
const refactorerStart = Date.now();
|
|
136
|
-
|
|
155
|
+
let refResult;
|
|
156
|
+
try {
|
|
157
|
+
refResult = await refRole.execute({ task: plannedTask, onOutput: refactorerStall.onOutput });
|
|
158
|
+
} finally {
|
|
159
|
+
refactorerStall.stop();
|
|
160
|
+
}
|
|
137
161
|
trackBudget({ role: "refactorer", provider: refactorerRole.provider, model: refactorerRole.model, result: refResult.result, duration_ms: Date.now() - refactorerStart });
|
|
138
162
|
if (!refResult.ok) {
|
|
139
163
|
const details = refResult.result?.error || refResult.summary || "unknown error";
|
|
@@ -392,19 +416,27 @@ export async function runReviewerStage({ reviewerRole, config, logger, emitter,
|
|
|
392
416
|
detail: { stream, agent: reviewerRole.provider }
|
|
393
417
|
}));
|
|
394
418
|
};
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
reviewerName: reviewerRole.provider,
|
|
398
|
-
config,
|
|
399
|
-
logger,
|
|
400
|
-
emitter,
|
|
401
|
-
reviewInput: { task, diff, reviewRules, onOutput: reviewerOnOutput },
|
|
402
|
-
session,
|
|
403
|
-
iteration,
|
|
404
|
-
onAttemptResult: ({ reviewer, result }) => {
|
|
405
|
-
trackBudget({ role: "reviewer", provider: reviewer, model: reviewerRole.model, result, duration_ms: Date.now() - reviewerStart });
|
|
406
|
-
}
|
|
419
|
+
const reviewerStall = createStallDetector({
|
|
420
|
+
onOutput: reviewerOnOutput, emitter, eventBase, stage: "reviewer", provider: reviewerRole.provider
|
|
407
421
|
});
|
|
422
|
+
const reviewerStart = Date.now();
|
|
423
|
+
let reviewerExec;
|
|
424
|
+
try {
|
|
425
|
+
reviewerExec = await runReviewerWithFallback({
|
|
426
|
+
reviewerName: reviewerRole.provider,
|
|
427
|
+
config,
|
|
428
|
+
logger,
|
|
429
|
+
emitter,
|
|
430
|
+
reviewInput: { task, diff, reviewRules, onOutput: reviewerStall.onOutput },
|
|
431
|
+
session,
|
|
432
|
+
iteration,
|
|
433
|
+
onAttemptResult: ({ reviewer, result }) => {
|
|
434
|
+
trackBudget({ role: "reviewer", provider: reviewer, model: reviewerRole.model, result, duration_ms: Date.now() - reviewerStart });
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
} finally {
|
|
438
|
+
reviewerStall.stop();
|
|
439
|
+
}
|
|
408
440
|
|
|
409
441
|
if (!reviewerExec.execResult || !reviewerExec.execResult.ok) {
|
|
410
442
|
const lastAttempt = reviewerExec.attempts.at(-1);
|
|
@@ -6,6 +6,7 @@ import { addCheckpoint, markSessionStatus } from "../session-store.js";
|
|
|
6
6
|
import { emitProgress, makeEvent } from "../utils/events.js";
|
|
7
7
|
import { parsePlannerOutput } from "../prompts/planner.js";
|
|
8
8
|
import { selectModelsForRoles } from "../utils/model-selector.js";
|
|
9
|
+
import { createStallDetector } from "../utils/stall-detector.js";
|
|
9
10
|
|
|
10
11
|
export async function runTriageStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget }) {
|
|
11
12
|
logger.setContext({ iteration: 0, stage: "triage" });
|
|
@@ -16,10 +17,26 @@ export async function runTriageStage({ config, logger, emitter, eventBase, sessi
|
|
|
16
17
|
})
|
|
17
18
|
);
|
|
18
19
|
|
|
20
|
+
const triageProvider = config?.roles?.triage?.provider || coderRole.provider;
|
|
21
|
+
const triageOnOutput = ({ stream, line }) => {
|
|
22
|
+
emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "triage" }, {
|
|
23
|
+
message: line,
|
|
24
|
+
detail: { stream, agent: triageProvider }
|
|
25
|
+
}));
|
|
26
|
+
};
|
|
27
|
+
const triageStall = createStallDetector({
|
|
28
|
+
onOutput: triageOnOutput, emitter, eventBase, stage: "triage", provider: triageProvider
|
|
29
|
+
});
|
|
30
|
+
|
|
19
31
|
const triage = new TriageRole({ config, logger, emitter });
|
|
20
32
|
await triage.init({ task: session.task, sessionId: session.id, iteration: 0 });
|
|
21
33
|
const triageStart = Date.now();
|
|
22
|
-
|
|
34
|
+
let triageOutput;
|
|
35
|
+
try {
|
|
36
|
+
triageOutput = await triage.run({ task: session.task, onOutput: triageStall.onOutput });
|
|
37
|
+
} finally {
|
|
38
|
+
triageStall.stop();
|
|
39
|
+
}
|
|
23
40
|
trackBudget({
|
|
24
41
|
role: "triage",
|
|
25
42
|
provider: config?.roles?.triage?.provider || coderRole.provider,
|
|
@@ -115,10 +132,26 @@ export async function runResearcherStage({ config, logger, emitter, eventBase, s
|
|
|
115
132
|
})
|
|
116
133
|
);
|
|
117
134
|
|
|
135
|
+
const researcherProvider = config?.roles?.researcher?.provider || coderRole.provider;
|
|
136
|
+
const researcherOnOutput = ({ stream, line }) => {
|
|
137
|
+
emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "researcher" }, {
|
|
138
|
+
message: line,
|
|
139
|
+
detail: { stream, agent: researcherProvider }
|
|
140
|
+
}));
|
|
141
|
+
};
|
|
142
|
+
const researcherStall = createStallDetector({
|
|
143
|
+
onOutput: researcherOnOutput, emitter, eventBase, stage: "researcher", provider: researcherProvider
|
|
144
|
+
});
|
|
145
|
+
|
|
118
146
|
const researcher = new ResearcherRole({ config, logger, emitter });
|
|
119
147
|
await researcher.init({ task: session.task });
|
|
120
148
|
const researchStart = Date.now();
|
|
121
|
-
|
|
149
|
+
let researchOutput;
|
|
150
|
+
try {
|
|
151
|
+
researchOutput = await researcher.run({ task: session.task, onOutput: researcherStall.onOutput });
|
|
152
|
+
} finally {
|
|
153
|
+
researcherStall.stop();
|
|
154
|
+
}
|
|
122
155
|
trackBudget({
|
|
123
156
|
role: "researcher",
|
|
124
157
|
provider: config?.roles?.researcher?.provider || coderRole.provider,
|
|
@@ -160,11 +193,26 @@ export async function runPlannerStage({ config, logger, emitter, eventBase, sess
|
|
|
160
193
|
})
|
|
161
194
|
);
|
|
162
195
|
|
|
196
|
+
const plannerOnOutput = ({ stream, line }) => {
|
|
197
|
+
emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "planner" }, {
|
|
198
|
+
message: line,
|
|
199
|
+
detail: { stream, agent: plannerRole.provider }
|
|
200
|
+
}));
|
|
201
|
+
};
|
|
202
|
+
const plannerStall = createStallDetector({
|
|
203
|
+
onOutput: plannerOnOutput, emitter, eventBase, stage: "planner", provider: plannerRole.provider
|
|
204
|
+
});
|
|
205
|
+
|
|
163
206
|
const planRole = new PlannerRole({ config, logger, emitter, createAgentFn: createAgent });
|
|
164
207
|
planRole.context = { task, research: researchContext, triageDecomposition };
|
|
165
208
|
await planRole.init();
|
|
166
209
|
const plannerStart = Date.now();
|
|
167
|
-
|
|
210
|
+
let planResult;
|
|
211
|
+
try {
|
|
212
|
+
planResult = await planRole.execute({ task, onOutput: plannerStall.onOutput });
|
|
213
|
+
} finally {
|
|
214
|
+
plannerStall.stop();
|
|
215
|
+
}
|
|
168
216
|
trackBudget({ role: "planner", provider: plannerRole.provider, model: plannerRole.model, result: planResult.result, duration_ms: Date.now() - plannerStart });
|
|
169
217
|
await addCheckpoint(session, {
|
|
170
218
|
stage: "planner",
|
|
@@ -65,15 +65,20 @@ export class PlannerRole extends BaseRole {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
async execute(input) {
|
|
68
|
-
const task = input
|
|
68
|
+
const { task, onOutput } = typeof input === "string"
|
|
69
|
+
? { task: input, onOutput: null }
|
|
70
|
+
: { task: input?.task || input || "", onOutput: input?.onOutput || null };
|
|
71
|
+
const taskStr = task || this.context?.task || "";
|
|
69
72
|
const research = this.context?.research || null;
|
|
70
73
|
const triageDecomposition = this.context?.triageDecomposition || null;
|
|
71
74
|
const provider = resolveProvider(this.config);
|
|
72
75
|
|
|
73
76
|
const agent = this._createAgent(provider, this.config, this.logger);
|
|
74
|
-
const prompt = buildPrompt({ task, instructions: this.instructions, research, triageDecomposition });
|
|
77
|
+
const prompt = buildPrompt({ task: taskStr, instructions: this.instructions, research, triageDecomposition });
|
|
75
78
|
|
|
76
|
-
const
|
|
79
|
+
const runArgs = { prompt, role: "planner" };
|
|
80
|
+
if (onOutput) runArgs.onOutput = onOutput;
|
|
81
|
+
const result = await agent.runTask(runArgs);
|
|
77
82
|
|
|
78
83
|
if (!result.ok) {
|
|
79
84
|
return {
|
|
@@ -36,12 +36,15 @@ export class RefactorerRole extends BaseRole {
|
|
|
36
36
|
const task = typeof input === "string"
|
|
37
37
|
? input
|
|
38
38
|
: input?.task || this.context?.task || "";
|
|
39
|
+
const onOutput = typeof input === "string" ? null : input?.onOutput || null;
|
|
39
40
|
|
|
40
41
|
const provider = resolveProvider(this.config);
|
|
41
42
|
const agent = this._createAgent(provider, this.config, this.logger);
|
|
42
43
|
|
|
43
44
|
const prompt = buildPrompt({ task, instructions: this.instructions });
|
|
44
|
-
const
|
|
45
|
+
const runArgs = { prompt, role: "refactorer" };
|
|
46
|
+
if (onOutput) runArgs.onOutput = onOutput;
|
|
47
|
+
const result = await agent.runTask(runArgs);
|
|
45
48
|
|
|
46
49
|
if (!result.ok) {
|
|
47
50
|
return {
|
|
@@ -64,12 +64,15 @@ export class ResearcherRole extends BaseRole {
|
|
|
64
64
|
const task = typeof input === "string"
|
|
65
65
|
? input
|
|
66
66
|
: input?.task || this.context?.task || "";
|
|
67
|
+
const onOutput = typeof input === "string" ? null : input?.onOutput || null;
|
|
67
68
|
|
|
68
69
|
const provider = resolveProvider(this.config);
|
|
69
70
|
const agent = this._createAgent(provider, this.config, this.logger);
|
|
70
71
|
|
|
71
72
|
const prompt = buildPrompt({ task, instructions: this.instructions });
|
|
72
|
-
const
|
|
73
|
+
const runArgs = { prompt, role: "researcher" };
|
|
74
|
+
if (onOutput) runArgs.onOutput = onOutput;
|
|
75
|
+
const result = await agent.runTask(runArgs);
|
|
73
76
|
|
|
74
77
|
if (!result.ok) {
|
|
75
78
|
return {
|
package/src/roles/triage-role.js
CHANGED
|
@@ -67,12 +67,15 @@ export class TriageRole extends BaseRole {
|
|
|
67
67
|
const task = typeof input === "string"
|
|
68
68
|
? input
|
|
69
69
|
: input?.task || this.context?.task || "";
|
|
70
|
+
const onOutput = typeof input === "string" ? null : input?.onOutput || null;
|
|
70
71
|
|
|
71
72
|
const provider = resolveProvider(this.config);
|
|
72
73
|
const agent = this._createAgent(provider, this.config, this.logger);
|
|
73
74
|
|
|
74
75
|
const prompt = buildPrompt({ task, instructions: this.instructions });
|
|
75
|
-
const
|
|
76
|
+
const runArgs = { prompt, role: "triage" };
|
|
77
|
+
if (onOutput) runArgs.onOutput = onOutput;
|
|
78
|
+
const result = await agent.runTask(runArgs);
|
|
76
79
|
|
|
77
80
|
if (!result.ok) {
|
|
78
81
|
return {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based run logger.
|
|
3
|
+
*
|
|
4
|
+
* Writes progress events to a known file so that external tools
|
|
5
|
+
* (tail -f, kj_status, another Claude process) can monitor what
|
|
6
|
+
* Karajan is doing in real time.
|
|
7
|
+
*
|
|
8
|
+
* Log location: <projectDir>/.kj/run.log (overwritten each run)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
const LOG_FILENAME = "run.log";
|
|
15
|
+
|
|
16
|
+
function resolveLogDir(baseDir) {
|
|
17
|
+
return path.join(baseDir || process.cwd(), ".kj");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveLogPath(baseDir) {
|
|
21
|
+
return path.join(resolveLogDir(baseDir), LOG_FILENAME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureDir(dir) {
|
|
25
|
+
try {
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
} catch { /* already exists */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatLine(event) {
|
|
31
|
+
const ts = new Date().toISOString().slice(11, 23);
|
|
32
|
+
const stage = event.stage || event.detail?.stage || "";
|
|
33
|
+
const type = event.type || "info";
|
|
34
|
+
const msg = event.message || "";
|
|
35
|
+
const extra = [];
|
|
36
|
+
|
|
37
|
+
if (event.detail?.provider) extra.push(`agent=${event.detail.provider}`);
|
|
38
|
+
if (event.detail?.lineCount !== undefined) extra.push(`lines=${event.detail.lineCount}`);
|
|
39
|
+
if (event.detail?.elapsedMs !== undefined) extra.push(`elapsed=${Math.round(event.detail.elapsedMs / 1000)}s`);
|
|
40
|
+
if (event.detail?.silenceMs !== undefined) extra.push(`silence=${Math.round(event.detail.silenceMs / 1000)}s`);
|
|
41
|
+
if (event.detail?.severity) extra.push(`severity=${event.detail.severity}`);
|
|
42
|
+
if (event.detail?.stream) extra.push(`stream=${event.detail.stream}`);
|
|
43
|
+
|
|
44
|
+
const extraStr = extra.length ? ` (${extra.join(", ")})` : "";
|
|
45
|
+
return `${ts} [${type}] ${stage ? `[${stage}] ` : ""}${msg}${extraStr}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createRunLog(projectDir) {
|
|
49
|
+
const logPath = resolveLogPath(projectDir);
|
|
50
|
+
const logDir = resolveLogDir(projectDir);
|
|
51
|
+
ensureDir(logDir);
|
|
52
|
+
|
|
53
|
+
// Truncate/create the log file
|
|
54
|
+
fs.writeFileSync(logPath, `--- Karajan run started at ${new Date().toISOString()} ---\n`);
|
|
55
|
+
|
|
56
|
+
let fd = null;
|
|
57
|
+
try {
|
|
58
|
+
fd = fs.openSync(logPath, "a");
|
|
59
|
+
} catch {
|
|
60
|
+
// If we can't open for append, use writeFile fallback
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function write(line) {
|
|
64
|
+
try {
|
|
65
|
+
if (fd !== null) {
|
|
66
|
+
fs.writeSync(fd, line + "\n");
|
|
67
|
+
} else {
|
|
68
|
+
fs.appendFileSync(logPath, line + "\n");
|
|
69
|
+
}
|
|
70
|
+
} catch { /* best-effort */ }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function logEvent(event) {
|
|
74
|
+
write(formatLine(event));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function logText(text) {
|
|
78
|
+
const ts = new Date().toISOString().slice(11, 23);
|
|
79
|
+
write(`${ts} ${text}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function close() {
|
|
83
|
+
try {
|
|
84
|
+
if (fd !== null) {
|
|
85
|
+
fs.closeSync(fd);
|
|
86
|
+
fd = null;
|
|
87
|
+
}
|
|
88
|
+
} catch { /* best-effort */ }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
logEvent,
|
|
93
|
+
logText,
|
|
94
|
+
close,
|
|
95
|
+
get path() { return logPath; }
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read the current run log contents.
|
|
101
|
+
* Returns the last N lines (default 50).
|
|
102
|
+
*/
|
|
103
|
+
export function readRunLog(maxLines = 50, projectDir) {
|
|
104
|
+
const logPath = resolveLogPath(projectDir);
|
|
105
|
+
try {
|
|
106
|
+
const content = fs.readFileSync(logPath, "utf8");
|
|
107
|
+
const lines = content.split("\n").filter(Boolean);
|
|
108
|
+
const total = lines.length;
|
|
109
|
+
const shown = lines.slice(-maxLines);
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
path: logPath,
|
|
113
|
+
totalLines: total,
|
|
114
|
+
lines: shown,
|
|
115
|
+
summary: shown.join("\n")
|
|
116
|
+
};
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
path: logPath,
|
|
121
|
+
error: err.code === "ENOENT"
|
|
122
|
+
? "No active run log found. Start a run with kj_run first."
|
|
123
|
+
: `Failed to read log: ${err.message}`
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stall detector for agent execution.
|
|
3
|
+
*
|
|
4
|
+
* Wraps an onOutput callback to track activity and emit heartbeat / stall
|
|
5
|
+
* warnings when an agent stops producing output for too long.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const detector = createStallDetector({ onOutput, emitter, eventBase, stage, provider, stallTimeoutMs });
|
|
9
|
+
* // pass detector.onOutput to the agent
|
|
10
|
+
* // when done: detector.stop();
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { emitProgress, makeEvent } from "./events.js";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000; // heartbeat every 30s
|
|
16
|
+
const DEFAULT_STALL_TIMEOUT_MS = 120_000; // warn after 2min silence
|
|
17
|
+
const DEFAULT_CRITICAL_TIMEOUT_MS = 300_000; // critical after 5min silence
|
|
18
|
+
|
|
19
|
+
export function createStallDetector({
|
|
20
|
+
onOutput,
|
|
21
|
+
emitter,
|
|
22
|
+
eventBase,
|
|
23
|
+
stage,
|
|
24
|
+
provider,
|
|
25
|
+
heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
26
|
+
stallTimeoutMs = DEFAULT_STALL_TIMEOUT_MS,
|
|
27
|
+
criticalTimeoutMs = DEFAULT_CRITICAL_TIMEOUT_MS
|
|
28
|
+
}) {
|
|
29
|
+
let lastActivityAt = Date.now();
|
|
30
|
+
let lineCount = 0;
|
|
31
|
+
let bytesReceived = 0;
|
|
32
|
+
let stallWarned = false;
|
|
33
|
+
let criticalWarned = false;
|
|
34
|
+
let heartbeatTimer = null;
|
|
35
|
+
const startedAt = Date.now();
|
|
36
|
+
|
|
37
|
+
function emitHeartbeat() {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const silenceMs = now - lastActivityAt;
|
|
40
|
+
const elapsedMs = now - startedAt;
|
|
41
|
+
|
|
42
|
+
if (silenceMs >= criticalTimeoutMs && !criticalWarned) {
|
|
43
|
+
criticalWarned = true;
|
|
44
|
+
emitProgress(emitter, makeEvent("agent:stall", { ...eventBase, stage }, {
|
|
45
|
+
status: "critical",
|
|
46
|
+
message: `Agent ${provider} unresponsive for ${Math.round(silenceMs / 1000)}s — may be hung`,
|
|
47
|
+
detail: {
|
|
48
|
+
provider,
|
|
49
|
+
silenceMs,
|
|
50
|
+
elapsedMs,
|
|
51
|
+
lineCount,
|
|
52
|
+
bytesReceived,
|
|
53
|
+
severity: "critical"
|
|
54
|
+
}
|
|
55
|
+
}));
|
|
56
|
+
} else if (silenceMs >= stallTimeoutMs && !stallWarned) {
|
|
57
|
+
stallWarned = true;
|
|
58
|
+
emitProgress(emitter, makeEvent("agent:stall", { ...eventBase, stage }, {
|
|
59
|
+
status: "warning",
|
|
60
|
+
message: `Agent ${provider} silent for ${Math.round(silenceMs / 1000)}s — still waiting`,
|
|
61
|
+
detail: {
|
|
62
|
+
provider,
|
|
63
|
+
silenceMs,
|
|
64
|
+
elapsedMs,
|
|
65
|
+
lineCount,
|
|
66
|
+
bytesReceived,
|
|
67
|
+
severity: "warning"
|
|
68
|
+
}
|
|
69
|
+
}));
|
|
70
|
+
} else if (silenceMs < stallTimeoutMs) {
|
|
71
|
+
// Reset warning flags when activity resumes
|
|
72
|
+
stallWarned = false;
|
|
73
|
+
criticalWarned = false;
|
|
74
|
+
|
|
75
|
+
emitProgress(emitter, makeEvent("agent:heartbeat", { ...eventBase, stage }, {
|
|
76
|
+
message: `Agent ${provider} active — ${lineCount} lines, ${Math.round(elapsedMs / 1000)}s elapsed`,
|
|
77
|
+
detail: {
|
|
78
|
+
provider,
|
|
79
|
+
elapsedMs,
|
|
80
|
+
lineCount,
|
|
81
|
+
bytesReceived
|
|
82
|
+
}
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Start periodic heartbeat
|
|
88
|
+
heartbeatTimer = setInterval(emitHeartbeat, heartbeatIntervalMs);
|
|
89
|
+
|
|
90
|
+
function wrappedOnOutput(data) {
|
|
91
|
+
lastActivityAt = Date.now();
|
|
92
|
+
lineCount++;
|
|
93
|
+
bytesReceived += data.line?.length || 0;
|
|
94
|
+
|
|
95
|
+
// Reset stall flags on new activity
|
|
96
|
+
stallWarned = false;
|
|
97
|
+
criticalWarned = false;
|
|
98
|
+
|
|
99
|
+
// Forward to the original callback
|
|
100
|
+
if (onOutput) {
|
|
101
|
+
onOutput(data);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function stop() {
|
|
106
|
+
if (heartbeatTimer) {
|
|
107
|
+
clearInterval(heartbeatTimer);
|
|
108
|
+
heartbeatTimer = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stats() {
|
|
113
|
+
return {
|
|
114
|
+
lineCount,
|
|
115
|
+
bytesReceived,
|
|
116
|
+
elapsedMs: Date.now() - startedAt,
|
|
117
|
+
lastActivityMs: Date.now() - lastActivityAt
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
onOutput: wrappedOnOutput,
|
|
123
|
+
stop,
|
|
124
|
+
stats
|
|
125
|
+
};
|
|
126
|
+
}
|