From 9e152fac93774d97e6e3f922a91be5acbc541b23 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 13 Apr 2026 08:38:44 +0900 Subject: [PATCH] runner: narrow Claude task progress subagent mapping --- docs/checklist-continuation-mode-plan.md | 154 ++++++++++++++ runners/agent-runner/src/index.ts | 64 +++--- .../agent-runner/src/task-progress-mapping.ts | 188 ++++++++++++++++++ .../test/task-progress-mapping.test.ts | 128 ++++++++++++ 4 files changed, 500 insertions(+), 34 deletions(-) create mode 100644 docs/checklist-continuation-mode-plan.md create mode 100644 runners/agent-runner/src/task-progress-mapping.ts create mode 100644 runners/agent-runner/test/task-progress-mapping.test.ts diff --git a/docs/checklist-continuation-mode-plan.md b/docs/checklist-continuation-mode-plan.md new file mode 100644 index 0000000..277e1fa --- /dev/null +++ b/docs/checklist-continuation-mode-plan.md @@ -0,0 +1,154 @@ +# Checklist Continuation Mode Plan + +## 목표 + +- 장기 리팩토링/장기 과제가 중간 `DONE`에서 끊기지 않게 한다. +- `DONE`의 의미는 유지한다. + - `DONE` = 이번 step 승인 + - task 종료 = 마지막 step 완료 +- `DONE_WITH_CONCERNS`를 continuation 용도로 남용하지 않게 한다. + +## 원칙 + +- 새 상태값/새 테이블/마이그레이션부터 늘리지 않는다. +- 기존 `paired_tasks.plan_notes`를 versioned JSON으로 재사용한다. +- split point는 reviewer `DONE`이 아니라 owner finalize 직전이다. +- Arbiter/deadlock은 step 단위로 보고, 장기 stagnation은 plan 단위로 본다. + +## 단계 전략 + +### 1. Lite Plan Mode + +복잡도를 크게 늘리지 않고 플랜모드의 UX만 먼저 도입한다. + +- `plan_notes`에 체크리스트 저장 +- 명령: + - `플랜 시작: 1) ... 2) ...` + - `플랜 상태` + - `플랜 중지` +- 현재 step / 남은 step / 직전 요약만 표시 +- 다음 step은 사용자가 수동으로 진행 + +장점: + +- 상태 머신을 거의 건드리지 않는다 +- 기존 owner/reviewer/arbiter 루프와 충돌이 적다 +- checklist 기반 장기 작업 UX를 바로 제공할 수 있다 + +제한: + +- `DONE` 후 자동 다음 step은 없다 +- fresh session 자동 절단도 없다 + +### 2. Real Continuation Mode (MVP) + +자동 continuation이 필요한 경우에만 확장한다. + +- `plan_notes` JSON 스키마 + 파서 +- 플랜 시작 / 상태 / 중지 명령 +- owner finalize continuation 분기 +- `round_trip_count` step 단위 리셋 +- fresh session prompt wiring +- 테스트 + +## `plan_notes` JSON 스키마 + +```json +{ + "version": 1, + "mode": "planned", + "items": [ + { + "id": "step-1", + "title": "A 리팩토링", + "status": "done", + "summary": "직전 step 요약", + "changedFiles": ["src/a.ts"] + }, + { + "id": "step-2", + "title": "B 리팩토링", + "status": "in_progress" + }, + { + "id": "step-3", + "title": "C 리팩토링", + "status": "pending" + } + ], + "currentIndex": 1, + "autoContinueOnDone": true, + "maxAutoTurns": 6, + "autoTurnsUsed": 2, + "lastStepSummary": "A 완료" +} +``` + +판별 규칙: + +- `plan_notes`가 versioned JSON이고 `mode: "planned"`일 때만 continuation mode로 해석 +- 그 외 값은 legacy/freeform note로 유지 + +## 구현 포인트 + +### owner finalize 분기 + +- 위치: `src/paired-execution-context-owner.ts` +- reviewer 승인 후 owner finalize가 `DONE`이고 남은 checklist item이 있으면: + - 현재 item -> `done` + - 다음 item -> `in_progress` + - `round_trip_count = 0` + - `autoTurnsUsed += 1` + - `lastStepSummary`, `changedFiles` 저장 + - task status는 `completed`가 아니라 continuation 가능한 상태로 유지 +- 마지막 item일 때만 `completed` + +### 다음 owner step 자동 시작 + +- 위치: `src/message-runtime-rules.ts` +- planned task는 `active + lastTurnOutputRole=owner`라도 `owner-follow-up`으로 이어질 수 있어야 한다 + +### compact owner prompt + +- 위치: `src/message-runtime-prompts.ts` +- planned continuation에서는 기존 pending prompt를 그대로 재사용하지 않는다 +- 다음 step prompt에는 최소한 아래만 전달한다 + - 현재 checklist item + - 직전 step 요약 + - 변경 파일 목록 + - 남은 item 목록 + +## 외부 프로젝트에서 차용할 것 + +### Archon + +- checklist step 진행 +- fresh context +- approval gate 발상 + +### OpenHarness + +- permission mode 분리 +- auto-compaction 같은 운영 아이디어 + +## 외부 프로젝트에서 차용하지 않을 것 + +- Archon의 YAML DAG/workflow 엔진 전체 +- OpenHarness의 `plan mode` 자체 + - 이쪽은 continuation 엔진이 아니라 permission toggle에 가깝다 +- 프레임워크/런타임 통째 교체 + +## 구현 순서 제안 + +1. Lite Plan Mode +2. 실제 사용 패턴 확인 +3. Real Continuation Mode MVP +4. 이후 compaction / permission polish는 별도 enhancement + +## out of scope + +- step 순서 변경 +- step 분할/병합 +- 중간 삽입/삭제 +- 새 테이블 도입 +- auto-compaction 본체 구현 diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 68d10ad..5a7ad62 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -48,6 +48,12 @@ import { type RunnerOutput, writeOutput, } from './output-protocol.js'; +import { + TopLevelAgentTaskTracker, + buildTaskNotificationOutput, + buildTaskProgressOutput, + buildTaskStartedOutput, +} from './task-progress-mapping.js'; import { createPreCompactHook, createReviewerBashGuardHook, @@ -153,6 +159,7 @@ async function runQuery( let resultCount = 0; let terminalResultObserved = false; let pendingProgressText: string | null = null; + const trackedAgentTasks = new TopLevelAgentTaskTracker(); // Discover additional directories const extraDirs: string[] = []; @@ -366,25 +373,20 @@ async function runQuery( ) { const tn = message as { task_id: string; + tool_use_id?: string; status: string; summary: string; }; log( `Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`, ); - if ( - tn.status === 'completed' || - tn.status === 'error' || - tn.status === 'cancelled' - ) { - writeOutput({ - status: 'success', - phase: 'progress', - agentId: tn.task_id, - agentDone: true, - result: tn.summary || null, - newSessionId, - }); + const mapped = buildTaskNotificationOutput( + trackedAgentTasks, + tn, + newSessionId, + ); + if (mapped) { + writeOutput(mapped); } } @@ -393,20 +395,16 @@ async function runQuery( (message as { subtype?: string }).subtype === 'task_progress' ) { const tp = message as Record; - const taskId = typeof tp.task_id === 'string' ? tp.task_id : undefined; const summary = typeof tp.summary === 'string' ? tp.summary : ''; const description = typeof tp.description === 'string' ? tp.description : ''; - if (description && description.length <= 80) { - // Short tool description → show as sub-line in progress - const normalized = normalizeStructuredOutput(description); - writeOutput({ - status: 'success', - phase: 'tool-activity', - ...normalized, - agentId: taskId, - newSessionId, - }); + const mapped = buildTaskProgressOutput( + trackedAgentTasks, + tp, + newSessionId, + ); + if (mapped) { + writeOutput(mapped); } else if (description) { // Long AI summary → skip (too long for progress sub-line) log( @@ -422,16 +420,13 @@ async function runQuery( const ts = message as { task_id: string; description?: string }; const desc = ts.description || ''; log(`Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`); - if (desc) { - const normalized = normalizeStructuredOutput(`🔄 ${desc}`); - writeOutput({ - status: 'success', - phase: 'progress', - ...normalized, - agentId: ts.task_id, - agentLabel: desc, - newSessionId, - }); + const mapped = buildTaskStartedOutput( + trackedAgentTasks, + ts, + newSessionId, + ); + if (mapped) { + writeOutput(mapped); } } @@ -542,6 +537,7 @@ async function runQuery( } if (message.type === 'assistant') { + trackedAgentTasks.rememberAssistantMessage(message); const stopReason = (message as { stop_reason?: string }).stop_reason; const textResult = extractAssistantText(message); // Only log when there's something interesting (text or terminal) diff --git a/runners/agent-runner/src/task-progress-mapping.ts b/runners/agent-runner/src/task-progress-mapping.ts new file mode 100644 index 0000000..94b579b --- /dev/null +++ b/runners/agent-runner/src/task-progress-mapping.ts @@ -0,0 +1,188 @@ +import { + normalizeStructuredOutput, + type RunnerOutput, +} from './output-protocol.js'; + +type AssistantToolUseBlock = { + type?: unknown; + id?: unknown; + name?: unknown; + caller?: { type?: unknown } | null; + input?: { description?: unknown } | null; +}; + +type AssistantMessageLike = { + type?: unknown; + message?: { + content?: unknown; + }; +}; + +type TaskLike = { + task_id?: unknown; + tool_use_id?: unknown; + description?: unknown; + status?: unknown; + summary?: unknown; +}; + +function toNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function getTaskText(message: TaskLike): { + taskId?: string; + toolUseId?: string; + description?: string; + summary?: string; +} { + return { + taskId: toNonEmptyString(message.task_id), + toolUseId: toNonEmptyString(message.tool_use_id), + description: toNonEmptyString(message.description), + summary: toNonEmptyString(message.summary), + }; +} + +export class TopLevelAgentTaskTracker { + private readonly topLevelToolUses = new Map(); + private readonly taskLabels = new Map(); + + rememberAssistantMessage(message: unknown): void { + const assistant = message as AssistantMessageLike; + const blocks = assistant.message?.content; + if (!Array.isArray(blocks)) return; + + for (const block of blocks) { + const toolUse = block as AssistantToolUseBlock; + if (toolUse.type !== 'tool_use' || toolUse.name !== 'Agent') continue; + + const toolUseId = toNonEmptyString(toolUse.id); + if (!toolUseId) continue; + + const callerType = toolUse.caller?.type; + if (callerType !== undefined && callerType !== 'direct') continue; + + const description = + toNonEmptyString(toolUse.input?.description) || '서브에이전트 작업'; + this.topLevelToolUses.set(toolUseId, description); + } + } + + resolveTask(message: TaskLike): { + tracked: boolean; + taskId?: string; + label?: string; + } { + const { taskId, toolUseId, description } = getTaskText(message); + if (!taskId) { + return { tracked: false }; + } + + const existing = this.taskLabels.get(taskId); + if (existing) { + return { tracked: true, taskId, label: existing }; + } + + if (toolUseId) { + const topLevelLabel = this.topLevelToolUses.get(toolUseId); + if (topLevelLabel) { + const label = topLevelLabel || description || '서브에이전트 작업'; + this.taskLabels.set(taskId, label); + return { tracked: true, taskId, label }; + } + } + + return { tracked: false, taskId, label: description }; + } +} + +export function buildTaskStartedOutput( + tracker: TopLevelAgentTaskTracker, + message: TaskLike, + newSessionId?: string, +): RunnerOutput | null { + const { description } = getTaskText(message); + if (!description) return null; + + const resolved = tracker.resolveTask(message); + const label = resolved.label || description; + const normalized = normalizeStructuredOutput(`🔄 ${label}`); + + return { + status: 'success', + phase: 'progress', + ...normalized, + ...(resolved.tracked && resolved.taskId + ? { + agentId: resolved.taskId, + agentLabel: label, + } + : {}), + newSessionId, + }; +} + +export function buildTaskProgressOutput( + tracker: TopLevelAgentTaskTracker, + message: TaskLike, + newSessionId?: string, +): RunnerOutput | null { + const { description } = getTaskText(message); + if (!description || description.length > 80) return null; + + const resolved = tracker.resolveTask(message); + const normalized = normalizeStructuredOutput(description); + + return { + status: 'success', + phase: 'tool-activity', + ...normalized, + ...(resolved.tracked && resolved.taskId + ? { + agentId: resolved.taskId, + } + : {}), + newSessionId, + }; +} + +export function buildTaskNotificationOutput( + tracker: TopLevelAgentTaskTracker, + message: TaskLike, + newSessionId?: string, +): RunnerOutput | null { + const { summary } = getTaskText(message); + const status = toNonEmptyString(message.status); + if (!status) return null; + if ( + status !== 'completed' && + status !== 'failed' && + status !== 'stopped' && + status !== 'error' && + status !== 'cancelled' + ) { + return null; + } + + const resolved = tracker.resolveTask(message); + + return { + status: 'success', + phase: 'progress', + ...(summary !== undefined + ? { + ...normalizeStructuredOutput(summary), + } + : { result: null }), + ...(resolved.tracked && resolved.taskId + ? { + agentId: resolved.taskId, + agentDone: true, + } + : {}), + newSessionId, + }; +} diff --git a/runners/agent-runner/test/task-progress-mapping.test.ts b/runners/agent-runner/test/task-progress-mapping.test.ts new file mode 100644 index 0000000..91e7d54 --- /dev/null +++ b/runners/agent-runner/test/task-progress-mapping.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; + +import { + TopLevelAgentTaskTracker, + buildTaskNotificationOutput, + buildTaskProgressOutput, + buildTaskStartedOutput, +} from '../src/task-progress-mapping.js'; + +describe('task progress mapping', () => { + it('tracks only top-level Agent tool uses as subagents', () => { + const tracker = new TopLevelAgentTaskTracker(); + + tracker.rememberAssistantMessage({ + message: { + content: [ + { + type: 'tool_use', + id: 'toolu_top', + name: 'Agent', + caller: { type: 'direct' }, + input: { description: 'Find subagent enabling mechanism' }, + }, + { + type: 'tool_use', + id: 'toolu_nested', + name: 'Agent', + caller: { type: 'subagent' }, + input: { description: 'Nested helper task' }, + }, + ], + }, + }); + + const started = buildTaskStartedOutput( + tracker, + { + task_id: 'task-top', + tool_use_id: 'toolu_top', + description: 'Find subagent enabling mechanism', + }, + 'session-1', + ); + + const nested = buildTaskStartedOutput( + tracker, + { + task_id: 'task-nested', + tool_use_id: 'toolu_nested', + description: 'Nested helper task', + }, + 'session-1', + ); + + expect(started?.agentId).toBe('task-top'); + expect(started?.agentLabel).toBe('Find subagent enabling mechanism'); + expect(nested?.agentId).toBeUndefined(); + }); + + it('keeps task progress attached to the tracked top-level subagent', () => { + const tracker = new TopLevelAgentTaskTracker(); + + tracker.rememberAssistantMessage({ + message: { + content: [ + { + type: 'tool_use', + id: 'toolu_top', + name: 'Agent', + caller: { type: 'direct' }, + input: { description: 'Find codex config.toml location' }, + }, + ], + }, + }); + + buildTaskStartedOutput(tracker, { + task_id: 'task-top', + tool_use_id: 'toolu_top', + description: 'Find codex config.toml location', + }); + + const progress = buildTaskProgressOutput(tracker, { + task_id: 'task-top', + description: 'Extract exact hide_spawn_agent field names', + }); + + const notification = buildTaskNotificationOutput(tracker, { + task_id: 'task-top', + status: 'failed', + summary: 'Task failed', + }); + + expect(progress?.agentId).toBe('task-top'); + expect(progress?.result).toBe('Extract exact hide_spawn_agent field names'); + expect(notification?.agentId).toBe('task-top'); + expect(notification?.agentDone).toBe(true); + }); + + it('flattens untracked internal task progress into generic progress lines', () => { + const tracker = new TopLevelAgentTaskTracker(); + + const started = buildTaskStartedOutput(tracker, { + task_id: 'task-internal', + tool_use_id: 'toolu_internal', + description: 'Search Codex package for hide_spawn_agent config', + }); + + const progress = buildTaskProgressOutput(tracker, { + task_id: 'task-internal', + tool_use_id: 'toolu_internal', + description: 'Extract exact hide_spawn_agent field names', + }); + + const notification = buildTaskNotificationOutput(tracker, { + task_id: 'task-internal', + tool_use_id: 'toolu_internal', + status: 'completed', + summary: 'Finished searching config', + }); + + expect(started?.agentId).toBeUndefined(); + expect(progress?.agentId).toBeUndefined(); + expect(progress?.result).toBe('Extract exact hide_spawn_agent field names'); + expect(notification?.agentId).toBeUndefined(); + expect(notification?.agentDone).toBeUndefined(); + }); +});