Skip to content

Commit 4975686

Browse files
recuu-pfegclaude
andcommitted
feat: improve agent retry flow — previous review injection, no-commit completion, task logger
- Inject previous review feedback into agent prompt on retry so agents know what went wrong last time (NEEDS_CHANGES reason) - Add allow_no_commit_completion template flag: agents that complete without code changes (e.g. task already done) go to review instead of infinite NEEDS_CHANGES loop - Extract completion from stream-json result when COMPLETION_JSON is not explicitly output by the agent - Record NEEDS_CHANGES failures to Failure DB via recordFailure() - Add per-task execution logger (~/.toban/logs/tasks/) for debugging agent pickup, completion parse, post_action results, and stdout Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c86575e commit 4975686

5 files changed

Lines changed: 206 additions & 6 deletions

File tree

src/agent-templates.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export interface AgentTemplate {
5656
/** Additional rules injected into the prompt */
5757
rules?: string[];
5858
};
59+
/** Allow task to pass review when agent reports completion without code commits */
60+
allow_no_commit_completion?: boolean;
5961
}
6062

6163
// ---------------------------------------------------------------------------
@@ -68,6 +70,7 @@ const DEFAULT_TEMPLATES: AgentTemplate[] = [
6870
name: "Implementation (default)",
6971
match: {},
7072
tools: "all",
73+
allow_no_commit_completion: true,
7174
pre_actions: [
7275
{ type: "git_auth_check", label: "Verify git push credentials" },
7376
{ type: "inject_memory", label: "Inject agent memory into CLAUDE.md" },
@@ -103,6 +106,7 @@ The CLI will automatically update the task status and submit this data. Do NOT m
103106
{
104107
id: "research",
105108
name: "Research / Investigation",
109+
allow_no_commit_completion: true,
106110
match: {
107111
task_types: ["research", "investigation", "analysis", "audit"],
108112
},
@@ -143,6 +147,7 @@ DO NOT: git add, git commit, git push, or modify any files. Only read and analyz
143147
{
144148
id: "content",
145149
name: "Content / Documentation",
150+
allow_no_commit_completion: true,
146151
match: {
147152
task_types: ["content", "docs", "documentation"],
148153
},
@@ -183,6 +188,7 @@ COMPLETION_JSON:{"review_comment":"<summary: what docs were created/updated, key
183188
{
184189
id: "strategy",
185190
name: "Strategy / Planning",
191+
allow_no_commit_completion: true,
186192
match: {
187193
task_types: ["strategy", "planning"],
188194
},
@@ -339,6 +345,12 @@ export interface ActionContext {
339345
preMergeHash?: string;
340346
/** Set to true if git_merge was skipped (no agent commits or metadata-only) */
341347
mergeSkipped?: boolean;
348+
/** Parsed COMPLETION_JSON from agent output (set by cli.ts after agent finishes) */
349+
completionJson?: { review_comment?: string; commits?: string };
350+
/** The matched template for this task */
351+
template?: AgentTemplate;
352+
/** Per-task logger for debugging */
353+
taskLog?: { event(name: string, data?: Record<string, unknown>): void };
342354
/** Merge function (injected from runner) */
343355
onMerge?: () => boolean;
344356
/** Retro submit function (injected from runner) */
@@ -376,9 +388,24 @@ export async function executeActions(
376388
const MAX_RETRIES = 3;
377389
const retryCount = (retryTracker.get(ctx.task.id) ?? 0) + 1;
378390
retryTracker.set(ctx.task.id, retryCount);
391+
392+
// Record failure to Failure DB
393+
const reviewComment = typeof ctx.task.review_comment === "string" ? ctx.task.review_comment : undefined;
394+
ctx.api.recordFailure({
395+
task_id: ctx.task.id,
396+
failure_type: "reject",
397+
summary: retryCount >= MAX_RETRIES
398+
? `Blocked after ${retryCount} failed attempts: ${ctx.task.title}`
399+
: `NEEDS_CHANGES (attempt ${retryCount}): ${ctx.task.title}`,
400+
agent_name: ctx.config.agentName,
401+
sprint: typeof ctx.task.sprint === "number" ? ctx.task.sprint : undefined,
402+
review_comment: reviewComment,
403+
}).catch(() => { /* best-effort */ });
404+
379405
if (retryCount >= MAX_RETRIES) {
380-
updates.status = "blocked";
381-
ui.error(`[${phase}] Task failed ${retryCount} times — blocked for human review`);
406+
updates.status = "review";
407+
updates.review_comment = `Blocked: task failed ${retryCount} times. Needs human intervention.`;
408+
ui.error(`[${phase}] Task failed ${retryCount} times — moved to review for human intervention`);
382409
} else {
383410
updates.status = "todo";
384411
ui.warn(`[${phase}] Review verdict: NEEDS_CHANGES (attempt ${retryCount}/${MAX_RETRIES}) — resetting to todo`);
@@ -568,8 +595,13 @@ export async function executeActions(
568595
}
569596
case "spawn_reviewer": {
570597
if (ctx.mergeSkipped) {
571-
ui.info(`[${phase}] ${label}: skipped (no merge)`);
572-
ctx.reviewVerdict = "NEEDS_CHANGES";
598+
const allowNoCommit = ctx.template?.allow_no_commit_completion ?? false;
599+
if (allowNoCommit && ctx.completionJson?.review_comment) {
600+
ui.info(`[${phase}] ${label}: no code changes, agent reported completion — sending to human review`);
601+
} else {
602+
ui.info(`[${phase}] ${label}: skipped (no merge${!ctx.completionJson ? ", no completion" : ""})`);
603+
ctx.reviewVerdict = "NEEDS_CHANGES";
604+
}
573605
break;
574606
}
575607
ctx.onReviewUpdate?.(ctx.task.id, "started");
@@ -1093,9 +1125,11 @@ ${outputFormat}`;
10931125
default:
10941126
ui.warn(`[template] Unknown action type: ${action.type}`);
10951127
}
1128+
ctx.taskLog?.event("action_ok", { action: action.type, label });
10961129
} catch (err) {
10971130
logError(CLI_ERR.ACTION_FAILED, `${phase} action "${label}" failed`, { taskId: ctx.task.id, action: action.type, phase }, err);
10981131
ui.warn(`[template] ${phase} action "${label}" failed: ${err}`);
1132+
ctx.taskLog?.event("action_error", { action: action.type, label, error: err instanceof Error ? err.message : String(err) });
10991133
}
11001134
}
11011135
}

src/api-client.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export interface ApiClient {
102102
fetchAgentMemories(agentName: string): Promise<AgentMemory[]>;
103103
putAgentMemory(agentName: string, key: string, data: { type: string; content: string }): Promise<void>;
104104
fetchRelevantFailures(): Promise<Array<{ summary: string; failure_type: string; agent_name: string | null; created_at: string }>>;
105+
recordFailure(data: { task_id: string; failure_type: string; summary: string; agent_name?: string; sprint?: number; review_comment?: string; files_involved?: string }): Promise<void>;
105106
}
106107

107108
export function createApiClient(apiUrl: string, apiKey: string): ApiClient {
@@ -330,5 +331,15 @@ export function createApiClient(apiUrl: string, apiKey: string): ApiClient {
330331
return [];
331332
}
332333
},
334+
335+
async recordFailure(data: { task_id: string; failure_type: string; summary: string; agent_name?: string; sprint?: number; review_comment?: string; files_involved?: string }): Promise<void> {
336+
try {
337+
await fetch(`${apiUrl}/api/v1/failures`, {
338+
method: "POST",
339+
headers,
340+
body: JSON.stringify(data),
341+
});
342+
} catch { /* best-effort */ }
343+
},
333344
};
334345
}

src/cli.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { ChatPoller } from "./chat-poller.js";
1818
import { MessagePoller } from "./message-poller.js";
1919
import { WS_MSG } from "./ws-types.js";
2020
import { resolveTaskWorkingDir } from "./git-ops.js";
21+
import { createTaskLogger } from "./task-logger.js";
2122
import { logError, CLI_ERR } from "./error-logger.js";
2223
import { ensureGitUser } from "./spawner.js";
2324
import { setup, type CliArgs, type SetupResult } from "./setup.js";
@@ -268,8 +269,11 @@ async function runLoop(cliArgs: CliArgs, runner: AgentRunner): Promise<void> {
268269
const isReadOnly = agentTemplate.tools !== "all";
269270
ui.info(`[task] Template: "${agentTemplate.id}"${isReadOnly ? ` (read-only: ${(agentTemplate.tools as string[]).join(", ")})` : ""}`);
270271

272+
const taskLog = createTaskLogger(task.id);
273+
taskLog.event("pickup", { agent: agentName, template: agentTemplate.id, title: task.title, taskType, hasReviewComment: !!(task as Record<string, unknown>).review_comment });
274+
271275
const actionCtx: ActionContext = {
272-
api, task, agentName,
276+
api, task, agentName, template: agentTemplate, taskLog,
273277
config: { apiUrl: cliArgs.apiUrl, apiKey: cliArgs.apiKey, workingDir: taskWorkingDir, baseBranch: cliArgs.baseBranch, sprintNumber: sprintData.sprint.number, language: ctx.language, engine: cliArgs.engine },
274278
onDataUpdate: (entity, id, changes) => {
275279
ctx.wsServer?.broadcast({
@@ -308,6 +312,31 @@ async function runLoop(cliArgs: CliArgs, runner: AgentRunner): Promise<void> {
308312
let pastFailures: Array<{ summary: string; failure_type: string; agent_name: string | null }> = [];
309313
try { pastFailures = await api.fetchRelevantFailures(); } catch { /* non-fatal */ }
310314

315+
// Extract previous review feedback for retry injection
316+
let previousReview: string | undefined;
317+
const taskReviewComment = (task as Record<string, unknown>).review_comment as string | undefined;
318+
if (taskReviewComment) {
319+
try {
320+
const r = JSON.parse(taskReviewComment);
321+
if (r.verdict === "NEEDS_CHANGES") {
322+
const parts = [`Verdict: ${r.verdict}`];
323+
if (r.requirement_match) parts.push(`Requirements: ${r.requirement_match}`);
324+
if (r.code_quality) parts.push(`Code quality: ${r.code_quality}`);
325+
if (r.risks) parts.push(`Risks: ${r.risks}`);
326+
previousReview = parts.join("\n");
327+
}
328+
} catch {
329+
if (taskReviewComment.startsWith("Blocked:") || taskReviewComment.includes("NEEDS_CHANGES")) {
330+
previousReview = taskReviewComment;
331+
}
332+
}
333+
}
334+
335+
if (previousReview) {
336+
ui.warn(`Injecting previous review feedback into agent prompt`);
337+
taskLog.event("previous_review_injected", { preview: previousReview.slice(0, 200) });
338+
}
339+
311340
const prompt = buildAgentPrompt({
312341
role: agentName, projectName: ctx.workspaceName, projectSpec: ctx.workspaceSpec,
313342
taskId: task.id, taskTitle: task.title,
@@ -319,6 +348,7 @@ async function runLoop(cliArgs: CliArgs, runner: AgentRunner): Promise<void> {
319348
targetRepo: task.target_repo ?? undefined,
320349
apiDocs: apiDocs || undefined, engineHint: getEngine(cliArgs.engine).promptHint,
321350
pastFailures: pastFailures.length > 0 ? pastFailures : undefined,
351+
previousReview,
322352
});
323353

324354
try {
@@ -386,6 +416,30 @@ async function runLoop(cliArgs: CliArgs, runner: AgentRunner): Promise<void> {
386416
});
387417
};
388418
// Extract COMPLETION_JSON from agent stdout → enrich post_action update_task
419+
// Extract COMPLETION_JSON or stream result from agent stdout
420+
// Fallback: if no COMPLETION_JSON found, check stream-json result event
421+
if (!actionCtx.completionJson) {
422+
for (const l of runningAgent.stdout) {
423+
try {
424+
const ev = JSON.parse(l);
425+
if (ev.type === "result" && ev.subtype === "success" && typeof ev.result === "string") {
426+
// Agent completed successfully but didn't output COMPLETION_JSON
427+
// Use the result text as review_comment
428+
const resultText = ev.result.slice(0, 2000);
429+
actionCtx.completionJson = { review_comment: resultText, commits: "" };
430+
for (const action of agentTemplate.post_actions) {
431+
if (action.type === "update_task" && action.when === "success" && action.params?.status === "review") {
432+
action.params = { ...action.params, review_comment: resultText, commits: "" };
433+
break;
434+
}
435+
}
436+
ui.info(`[completion] Extracted completion from stream result (no COMPLETION_JSON)`);
437+
taskLog.event("completion_parse", { source: "stream_result", review_comment: resultText.slice(0, 200) });
438+
break;
439+
}
440+
} catch { /* skip */ }
441+
}
442+
}
389443
for (const line of runningAgent.stdout) {
390444
const completionLine = line.startsWith("COMPLETION_JSON:") ? line : null;
391445
if (completionLine) {
@@ -398,7 +452,9 @@ async function runLoop(cliArgs: CliArgs, runner: AgentRunner): Promise<void> {
398452
break;
399453
}
400454
}
455+
actionCtx.completionJson = { review_comment: json.review_comment, commits: json.commits };
401456
ui.info(`[completion] Parsed COMPLETION_JSON: ${json.review_comment?.slice(0, 80)}...`);
457+
taskLog.event("completion_parse", { source: "completion_json", review_comment: json.review_comment?.slice(0, 200), commits: json.commits });
402458
// Broadcast review comment immediately for real-time dashboard update
403459
if (json.review_comment) {
404460
actionCtx.onReviewUpdate?.(task.id, "agent_submitted", json.review_comment);
@@ -419,6 +475,7 @@ async function runLoop(cliArgs: CliArgs, runner: AgentRunner): Promise<void> {
419475
break;
420476
}
421477
}
478+
actionCtx.completionJson = { review_comment: json.review_comment, commits: json.commits };
422479
ui.info(`[completion] Parsed COMPLETION_JSON from stream: ${json.review_comment?.slice(0, 80)}...`);
423480
// Broadcast review comment immediately for real-time dashboard update
424481
if (json.review_comment) {
@@ -493,13 +550,18 @@ async function runLoop(cliArgs: CliArgs, runner: AgentRunner): Promise<void> {
493550
}
494551
}
495552
};
553+
taskLog.stdout(runningAgent.stdout);
554+
taskLog.event("post_actions_start", { exitCode, mergeSkipped: actionCtx.mergeSkipped, hasCompletion: !!actionCtx.completionJson, reviewVerdict: actionCtx.reviewVerdict });
496555
await executeActions(agentTemplate.post_actions, actionCtx, "post");
556+
taskLog.event("post_actions_done", { reviewVerdict: actionCtx.reviewVerdict });
557+
taskLog.close();
497558
} catch (err) {
498559
logError(CLI_ERR.AGENT_SPAWN_FAILED, `Error spawning agent for task ${task.id}: ${err}`, { taskId: task.id, agentName }, err);
499560
ui.error(`Error spawning agent for task ${task.id}: ${err}`);
500-
// Use failure post_actions to reset task and notify user
561+
taskLog.event("error", { message: err instanceof Error ? err.message : String(err) });
501562
actionCtx.exitCode = 1;
502563
await executeActions(agentTemplate.post_actions, actionCtx, "post");
564+
taskLog.close();
503565
} finally {
504566
scheduler.releaseSlot(slotName);
505567
}

src/prompt.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export interface PromptContext {
4646
engineHint?: string;
4747
/** Past failures relevant to this task (from Failure Database) */
4848
pastFailures?: Array<{ summary: string; failure_type: string; agent_name: string | null }>;
49+
/** Previous review comment from a failed attempt (injected on retry) */
50+
previousReview?: string;
4951
}
5052

5153
const ROLE_DESCRIPTIONS: Record<string, string> = {
@@ -189,13 +191,18 @@ export function buildAgentPrompt(ctx: PromptContext): string {
189191
? `\n\n## Past Failures (avoid repeating these)\n${ctx.pastFailures.map((f) => `- [${f.failure_type}] ${f.summary}`).join("\n")}\n`
190192
: "";
191193

194+
const previousReviewBlock = ctx.previousReview
195+
? `\n\n## Previous Review (IMPORTANT — fix these issues)\nThis task was previously attempted and rejected. You MUST address the reviewer's feedback:\n${ctx.previousReview}\n`
196+
: "";
197+
192198
// Context budget: estimate tokens (chars / 4) and trim low-priority sections
193199
const TOKEN_BUDGET = 30_000;
194200
const estimateTokens = (s: string) => Math.ceil(s.length / 4);
195201

196202
// Fixed sections (always included)
197203
const fixedParts = [roleDesc, langLine, projectLine, modeHeader, extraRules, engineHintLine,
198204
`\nYour task: ${ctx.taskTitle}${priorityLine}${typeLine}${targetRepoLine}${descriptionBlock}`,
205+
previousReviewBlock,
199206
completionInstructions];
200207
const fixedCost = fixedParts.reduce((sum, p) => sum + estimateTokens(p), 0);
201208

src/task-logger.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Per-task execution logger.
3+
*
4+
* Creates a JSON log file per task execution at ~/.toban/logs/tasks/{taskId}.jsonl
5+
* Each line is a timestamped event. Claude Code can read these files for debugging.
6+
*
7+
* Usage:
8+
* const log = createTaskLogger(taskId);
9+
* log.event("pickup", { agent: "builder", template: "implementation" });
10+
* log.event("completion_parse", { source: "stream_result", review_comment: "..." });
11+
* log.event("post_action", { action: "spawn_reviewer", result: "skipped", reason: "no merge" });
12+
* log.stdout(lines); // persist last N stdout lines
13+
* log.close();
14+
*/
15+
16+
import fs from "node:fs";
17+
import path from "node:path";
18+
import os from "node:os";
19+
20+
const TASKS_LOG_DIR = path.join(os.homedir(), ".toban", "logs", "tasks");
21+
const MAX_STDOUT_LINES = 50;
22+
const MAX_FILES = 100; // keep last 100 task logs
23+
24+
interface TaskEvent {
25+
ts: string;
26+
event: string;
27+
data?: Record<string, unknown>;
28+
}
29+
30+
export interface TaskLogger {
31+
event(name: string, data?: Record<string, unknown>): void;
32+
stdout(lines: string[]): void;
33+
close(): void;
34+
}
35+
36+
function ensureDir() {
37+
try {
38+
fs.mkdirSync(TASKS_LOG_DIR, { recursive: true });
39+
} catch { /* best effort */ }
40+
}
41+
42+
function cleanOldLogs() {
43+
try {
44+
const files = fs.readdirSync(TASKS_LOG_DIR)
45+
.filter((f) => f.endsWith(".jsonl"))
46+
.map((f) => ({ name: f, time: fs.statSync(path.join(TASKS_LOG_DIR, f)).mtimeMs }))
47+
.sort((a, b) => b.time - a.time);
48+
49+
for (const f of files.slice(MAX_FILES)) {
50+
fs.unlinkSync(path.join(TASKS_LOG_DIR, f.name));
51+
}
52+
} catch { /* best effort */ }
53+
}
54+
55+
function write(filePath: string, entry: TaskEvent) {
56+
try {
57+
fs.appendFileSync(filePath, JSON.stringify(entry) + "\n");
58+
} catch { /* best effort */ }
59+
}
60+
61+
export function createTaskLogger(taskId: string): TaskLogger {
62+
ensureDir();
63+
cleanOldLogs();
64+
65+
const shortId = taskId.slice(0, 8);
66+
const filePath = path.join(TASKS_LOG_DIR, `${shortId}.jsonl`);
67+
68+
return {
69+
event(name: string, data?: Record<string, unknown>) {
70+
write(filePath, { ts: new Date().toISOString(), event: name, data });
71+
},
72+
73+
stdout(lines: string[]) {
74+
const tail = lines.slice(-MAX_STDOUT_LINES);
75+
write(filePath, {
76+
ts: new Date().toISOString(),
77+
event: "stdout_snapshot",
78+
data: { line_count: lines.length, tail },
79+
});
80+
},
81+
82+
close() {
83+
write(filePath, { ts: new Date().toISOString(), event: "close" });
84+
},
85+
};
86+
}

0 commit comments

Comments
 (0)