Skip to content

Commit 75d823f

Browse files
recuu-pfegclaude
andcommitted
fix: engine/model CLI reflection, Manager owner default, RETRO_JSON parse
- engine/model: pass DB engine to reviewer spawn, show model in UI - Manager proposals: default owner to 'builder' when missing - RETRO_JSON: bracket-matching extraction to handle trailing text Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4bb648e commit 75d823f

7 files changed

Lines changed: 100 additions & 22 deletions

File tree

src/agent-templates.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,8 @@ export interface ActionContext {
339339
sprintNumber?: number;
340340
language?: string;
341341
engine?: string;
342+
/** Agent's DB engine setting (e.g. "claude-opus") for model resolution */
343+
agentEngine?: string;
342344
};
343345
/** Agent exit code (only available in post_actions) */
344346
exitCode?: number | null;

src/commands/run-loop.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { extractCompletionJson } from "../utils/completion-parser.js";
2424
import { TIMEOUTS, INTERVALS } from "../constants.js";
2525
import { shouldSplit, autoSplitTasks } from "../task-splitter.js";
2626
import { detectDependencies, sortByDependency } from "../task-dependency.js";
27+
import { OpsRunner } from "../ops-runner.js";
28+
import { extractJsonObject } from "../utils/extract-json.js";
2729

2830
// ---------------------------------------------------------------------------
2931
// Helpers
@@ -63,6 +65,14 @@ export async function runLoop(cliArgs: CliArgs, runner: AgentRunner, shutdownSta
6365
// Start stall detection for agent processes
6466
runner.startStallDetection();
6567

68+
// Start ops task runner (background, parallel to main loop)
69+
const opsRunner = new OpsRunner({
70+
apiUrl: cliArgs.apiUrl,
71+
apiKey: cliArgs.apiKey,
72+
pollIntervalMs: 60_000,
73+
});
74+
opsRunner.start();
75+
6676
while (!shutdownState.shuttingDown) {
6777
try {
6878
sprintData = await api.fetchSprintData();
@@ -257,6 +267,7 @@ export async function runLoop(cliArgs: CliArgs, runner: AgentRunner, shutdownSta
257267
// Use slot name as agent name to avoid collisions in parallel dispatch
258268
const agentName = slotName;
259269
const agentRole = task.owner ?? "builder";
270+
const agentInfo = sprintData.agents.find((a) => a.name === agentRole);
260271
const apiDocs = await api.fetchApiDocs(agentRole);
261272
const taskType = task.type as string | undefined;
262273
const taskLabels = parseTaskLabels(task);
@@ -269,7 +280,7 @@ export async function runLoop(cliArgs: CliArgs, runner: AgentRunner, shutdownSta
269280

270281
const actionCtx: ActionContext = {
271282
api, task, agentName, template: agentTemplate, taskLog,
272-
config: { apiUrl: cliArgs.apiUrl, apiKey: cliArgs.apiKey, workingDir: taskWorkingDir, baseBranch: cliArgs.baseBranch, sprintNumber: sprintData.sprint.number, language: ctx.language, engine: cliArgs.engine },
283+
config: { apiUrl: cliArgs.apiUrl, apiKey: cliArgs.apiKey, workingDir: taskWorkingDir, baseBranch: cliArgs.baseBranch, sprintNumber: sprintData.sprint.number, language: ctx.language, engine: cliArgs.engine, agentEngine: agentInfo?.engine },
273284
onDataUpdate: (entity, id, changes) => {
274285
ctx.wsServer?.broadcast({
275286
type: WS_MSG.DATA_UPDATE,
@@ -358,7 +369,6 @@ export async function runLoop(cliArgs: CliArgs, runner: AgentRunner, shutdownSta
358369
if (ctx.gitUserInfo) ensureGitUser(taskWorkingDir, ctx.gitUserInfo.name, ctx.gitUserInfo.email);
359370

360371
// Resolve model from agent's DB engine setting or role default
361-
const agentInfo = sprintData.agents.find((a) => a.name === agentRole);
362372
const agentModel = resolveModelForRole(agentRole, agentInfo?.engine);
363373

364374
const agentConfig = {
@@ -380,7 +390,7 @@ export async function runLoop(cliArgs: CliArgs, runner: AgentRunner, shutdownSta
380390
continue;
381391
}
382392

383-
ui.agentSpawned({ agentName: agentConfig.name, taskId: task.id, taskTitle: task.title, docker: !cliArgs.noDocker });
393+
ui.agentSpawned({ agentName: agentConfig.name, taskId: task.id, taskTitle: task.title, docker: !cliArgs.noDocker, model: agentModel });
384394

385395
const messagePoller = new MessagePoller({ api, channel: agentRole, workingDir: taskWorkingDir });
386396
messagePoller.start();
@@ -437,13 +447,15 @@ export async function runLoop(cliArgs: CliArgs, runner: AgentRunner, shutdownSta
437447
for (const line of runningAgent.stdout) {
438448
let raw: string | null = null;
439449
if (line.startsWith("RETRO_JSON:")) {
440-
raw = line.slice("RETRO_JSON:".length);
450+
raw = extractJsonObject(line.slice("RETRO_JSON:".length));
441451
} else {
442452
try {
443453
const event = JSON.parse(line);
444454
const text = typeof event === "object" && event?.type === "assistant" ? (event.message?.content?.[0]?.text ?? "") : "";
445-
const m = text.match?.(/RETRO_JSON:(\{[\s\S]*\})/);
446-
if (m) raw = m[1];
455+
const retroIdx = text.indexOf?.("RETRO_JSON:");
456+
if (retroIdx !== undefined && retroIdx !== -1) {
457+
raw = extractJsonObject(text.slice(retroIdx + "RETRO_JSON:".length));
458+
}
447459
} catch { /* not JSON */ }
448460
}
449461
if (raw) {
@@ -459,13 +471,15 @@ export async function runLoop(cliArgs: CliArgs, runner: AgentRunner, shutdownSta
459471
for (const line of runningAgent.stdout) {
460472
let raw: string | null = null;
461473
if (line.startsWith("RETRO_JSON:")) {
462-
raw = line.slice("RETRO_JSON:".length);
474+
raw = extractJsonObject(line.slice("RETRO_JSON:".length));
463475
} else {
464476
try {
465477
const event = JSON.parse(line);
466478
if (event.type === "result" && typeof event.result === "string") {
467-
const match = event.result.match(/RETRO_JSON:(\{[\s\S]*\})/);
468-
if (match) raw = match[1];
479+
const retroIdx = event.result.indexOf("RETRO_JSON:");
480+
if (retroIdx !== -1) {
481+
raw = extractJsonObject(event.result.slice(retroIdx + "RETRO_JSON:".length));
482+
}
469483
}
470484
} catch { /* not JSON */ }
471485
}
@@ -568,6 +582,7 @@ export async function runLoop(cliArgs: CliArgs, runner: AgentRunner, shutdownSta
568582
}
569583
}
570584

585+
opsRunner.stop();
571586
await api.updateAgent({ name: cliArgs.agentName, status: "idle", activity: "Shut down" });
572587
ui.outro("Shutting down — goodbye");
573588
}

src/handlers/spawn-reviewer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as ui from "../ui.js";
1212
import { parseTaskLabels } from "../utils/parse-labels.js";
1313
import { spawnClaudeOnce } from "../utils/spawn-claude.js";
1414
import { resolveRepoRoot } from "../git-ops.js";
15+
import { resolveModelForRole } from "../agent-engine.js";
1516
import { TIMEOUTS, LIMITS } from "../constants.js";
1617

1718
export async function handleSpawnReviewer(
@@ -156,8 +157,10 @@ Output format: ${outputFormat}`;
156157
ctx.onReviewUpdate?.(ctx.task.id, "analyzing");
157158
ui.info(`[${phase}] ${label}: spawning Reviewer agent (${filesChanged.length} files)`);
158159

160+
// Use agent's DB engine setting for model resolution (respects dashboard config)
161+
const reviewerModel = resolveModelForRole("reviewer", ctx.config.agentEngine);
159162
const reviewResult = await spawnClaudeOnce(fullPrompt, {
160-
role: "reviewer", maxTurns: 5, timeout: TIMEOUTS.REVIEWER, cwd: reviewRepoDir,
163+
model: reviewerModel, role: "reviewer", maxTurns: 5, timeout: TIMEOUTS.REVIEWER, cwd: reviewRepoDir,
161164
});
162165

163166
// Parse COMPLETION_JSON from reviewer output (supports COMPLETION_JSON: prefix and ```json blocks)

src/manager-actions.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,13 @@ export function parseResponse(response: string): { reply: string; actions: Manag
161161
const tail = remaining.slice(lastEnd).trim();
162162
if (tail) replyLines.push(tail);
163163

164-
// Sanitize owner fields in proposals
164+
// Sanitize owner fields in proposals — ensure every proposal has a valid owner
165165
if (proposals) {
166166
for (const p of proposals) {
167-
if (p.owner && !VALID_OWNERS.includes(p.owner)) {
167+
if (!p.owner) {
168+
// Default to "builder" so CLI auto-starts the task
169+
p.owner = "builder";
170+
} else if (!VALID_OWNERS.includes(p.owner)) {
168171
const base = p.owner.split("-")[0];
169172
p.owner = VALID_OWNERS.includes(base) ? base : "builder";
170173
}

src/runner.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
tryMerge,
1010
} from "./spawner.js";
1111
import { getEngine, extractTextFromStreamJson } from "./agent-engine.js";
12+
import { extractJsonObject } from "./utils/extract-json.js";
1213
import {
1314
isDockerAvailable,
1415
isImageAvailable,
@@ -363,7 +364,8 @@ export class AgentRunner {
363364
// Direct match (non-stream-json or already extracted text)
364365
if (line.startsWith("RETRO_JSON:")) {
365366
try {
366-
const json = JSON.parse(line.slice("RETRO_JSON:".length));
367+
const jsonStr = extractJsonObject(line.slice("RETRO_JSON:".length)) ?? line.slice("RETRO_JSON:".length);
368+
const json = JSON.parse(jsonStr);
367369
return {
368370
agent_name: config.name,
369371
went_well: json.went_well || undefined,
@@ -380,15 +382,18 @@ export class AgentRunner {
380382
const event = JSON.parse(line);
381383
const text = extractTextFromStreamJson(event);
382384
if (text) {
383-
const retroMatch = text.match(/RETRO_JSON:(\{[\s\S]*\})/);
384-
if (retroMatch) {
385-
const json = JSON.parse(retroMatch[1]);
386-
return {
387-
agent_name: config.name,
388-
went_well: json.went_well || undefined,
389-
to_improve: json.to_improve || undefined,
390-
suggested_tasks: json.suggested_tasks || undefined,
391-
};
385+
const retroIdx = text.indexOf("RETRO_JSON:");
386+
if (retroIdx !== -1) {
387+
const jsonStr = extractJsonObject(text.slice(retroIdx + "RETRO_JSON:".length));
388+
if (jsonStr) {
389+
const json = JSON.parse(jsonStr);
390+
return {
391+
agent_name: config.name,
392+
went_well: json.went_well || undefined,
393+
to_improve: json.to_improve || undefined,
394+
suggested_tasks: json.suggested_tasks || undefined,
395+
};
396+
}
392397
}
393398
}
394399
} catch {

src/ui.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,16 @@ export function agentSpawned(opts: {
158158
repo?: string;
159159
container?: string;
160160
docker: boolean;
161+
model?: string;
161162
}): void {
162163
const mode = opts.docker ? color.dim("(container)") : color.dim("(host)");
163164
const lines = [
164165
`${color.cyan("●")} ${color.bold(opts.agentName)} ${mode}`,
165166
` task: ${opts.taskTitle}`,
166167
];
168+
if (opts.model) {
169+
lines.push(` model: ${opts.model}`);
170+
}
167171
if (opts.repo) {
168172
lines.push(` repo: ${opts.repo}`);
169173
}

src/utils/extract-json.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Extract the first valid JSON object from a string that may contain
3+
* trailing text after the JSON. Uses bracket matching to find the
4+
* correct closing brace.
5+
*
6+
* Example: '{"a":1} some extra text' -> '{"a":1}'
7+
*/
8+
export function extractJsonObject(str: string): string | null {
9+
const start = str.indexOf("{");
10+
if (start === -1) return null;
11+
12+
let depth = 0;
13+
let inString = false;
14+
let escape = false;
15+
16+
for (let i = start; i < str.length; i++) {
17+
const ch = str[i];
18+
19+
if (escape) {
20+
escape = false;
21+
continue;
22+
}
23+
24+
if (ch === "\\") {
25+
escape = true;
26+
continue;
27+
}
28+
29+
if (ch === '"') {
30+
inString = !inString;
31+
continue;
32+
}
33+
34+
if (inString) continue;
35+
36+
if (ch === "{") depth++;
37+
if (ch === "}") {
38+
depth--;
39+
if (depth === 0) {
40+
return str.slice(start, i + 1);
41+
}
42+
}
43+
}
44+
45+
return null;
46+
}

0 commit comments

Comments
 (0)