feat(agenthub): TSK-0074 — automatic agent progress logging to a task live console

- routes PATCH /tasks/🆔 uniformly appendTaskLog + publishLog for every status
  transition (claim/review/done/cancel/reopen) and on assign; best-effort, never
  fails the mutation. No double-emission (log rides the 'log' channel; fsWatch
  only watches .md so the .log append is not re-emitted).
- MCP agenthub_task_log tool + remoteClient.appendTaskLog + CLI 'task log <id>
  --text [--agent][--level]'
- agenthub_work LOOP reminder: 'Report meaningful progress with agenthub_task_log'
- taskDetail: Live Console panel — historic lines via readTaskLog + live tail via
  the named task-log SSE event filtered to this task id
- tests: +taskLog-live.test.ts (PATCH->one log event, no double change, historic
  render, POST /log, remoteClient)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-12 01:02:04 +02:00
parent 3f1fb76a84
commit 2c2193ec55
7 changed files with 214 additions and 1 deletions

View File

@ -1,5 +1,6 @@
import { input, select } from '@inquirer/prompts';
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js';
import { appendTaskLog } from '../../core/services/taskLogService.js';
import type { Task } from '../../core/schema.js';
export async function taskCreate(cwd: string, options: Partial<Task> = {}): Promise<void> {
@ -69,3 +70,12 @@ export function taskAssign(cwd: string, id: string, agentName: string): void {
assignTask(cwd, id, agentName);
console.log(`AgentHub: Task assigned ${id}${agentName}`);
}
export function taskLog(
cwd: string,
id: string,
entry: { text: string; agent?: string; level?: string },
): void {
const rec = appendTaskLog(cwd, id, entry);
console.log(`AgentHub: logged ${id} ${rec.text}`);
}

View File

@ -2,7 +2,7 @@ import { Command } from 'commander';
import { init } from './commands/init.js';
import { status } from './commands/status.js';
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js';
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign, taskLog } from './commands/task.js';
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
import { decisionCreate, decisionList } from './commands/decision.js';
import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js';
@ -365,6 +365,23 @@ export function createProgram(cwd: string): Command {
taskReopen(projectCwd, id);
}
});
taskCmd
.command('log <id>')
.description('Append a progress line to a task\'s live console (streams to open task-detail pages)')
.requiredOption('--text <text>', 'Progress line')
.option('--agent <agent>', 'Reporting agent')
.option('--level <level>', 'Log level (info | status | warn | error)')
.action(async (id, options: { text: string; agent?: string; level?: string }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
await remoteClient.appendTaskLog(serverUrl, id, { text: options.text, agent: options.agent, level: options.level });
console.log(`AgentHub: logged ${id} ${options.text}`);
});
} else {
taskLog(projectCwd, id, { text: options.text, agent: options.agent, level: options.level });
}
});
program.addCommand(taskCmd);
const handoffCmd = new Command('handoff').description('Manage handoffs');

View File

@ -1,6 +1,7 @@
import type { Task, Handoff, Decision, Memory, Message, ActivityItem } from '../core/schema.js';
import type { IndexEntry } from '../core/index.js';
import type { InboxMessage } from '../core/services/messageService.js';
import type { TaskLogEntry } from '../core/services/taskLogService.js';
export class RemoteError extends Error {
constructor(public status: number, message: string) {
@ -92,6 +93,14 @@ export const remoteClient = {
return request<ActivityItem[]>(baseUrl, 'GET', `/tasks/${id}/activity`);
},
async appendTaskLog(
baseUrl: string,
id: string,
entry: { text: string; agent?: string; level?: string },
): Promise<TaskLogEntry> {
return request<TaskLogEntry>(baseUrl, 'POST', `/tasks/${id}/log`, entry);
},
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
},

View File

@ -17,6 +17,7 @@ import {
doneTask,
} from '../core/services/taskService.js';
import { createHandoff, getHandoff } from '../core/services/handoffService.js';
import { appendTaskLog } from '../core/services/taskLogService.js';
import { createMessage, listInbox, markMessageRead } from '../core/services/messageService.js';
import { addMemory, searchMemory } from '../core/services/memoryService.js';
import { createDecision } from '../core/services/decisionService.js';
@ -213,6 +214,7 @@ export async function startMcpServer(cwd: string): Promise<void> {
'Never end your turn without relaunching agenthub_work.'
: 'STAY REACHABLE: the instant you finish handling this, call agenthub_work again — ' +
'run it in the background so the next task or message reaches you in realtime. ' +
'Report meaningful progress with agenthub_task_log while you work. ' +
'Never end your turn without relaunching agenthub_work.';
const immediate = await finder();
if (immediate) return asText({ ...immediate, loop: LOOP });
@ -262,6 +264,11 @@ export async function startMcpServer(cwd: string): Promise<void> {
{ id: z.string() },
async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id)));
server.tool('agenthub_task_log',
'Report meaningful progress on the task you are working on — one short line — so the architect can watch it live on the task console. Call it as you work (e.g. "wrote failing test", "green: 12 tests", "blocked on X").',
{ id: z.string(), text: z.string(), agent: z.string().optional(), level: z.string().optional() },
async ({ id, text, agent, level }) => asText(remote ? await remoteClient.appendTaskLog(serverUrl!, id, { text, agent, level }) : appendTaskLog(root, id, { text, agent, level })));
server.tool('agenthub_memory_add', 'Record a result / finding / blocker as a memory entry.',
{ title: z.string(), category: z.enum(['architecture', 'product', 'technical', 'implementation', 'lesson']).optional(), content: z.string() },
async (o) => asText(remote ? await remoteClient.addMemory(serverUrl!, o) : addMemory(root, o)));

View File

@ -210,6 +210,19 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// Token & cost rollup per agent (real recorded + time-estimated, clearly flagged).
app.get('/budget', async () => computeBudget(cwd));
// Auto-log a task status transition to its live console. Best-effort: the
// .log write + task-log SSE fan-out must never fail the underlying mutation.
// (No double-emission: publishLog rides the separate 'log' channel, and
// fsWatch only watches .md files, so the .log append is not re-emitted.)
const logTaskStatus = (id: string, text: string, agent?: string) => {
try {
const entry = appendTaskLog(cwd, id, { text, agent, level: 'status' });
eventBus.publishLog({ taskId: id, ...entry });
} catch {
/* logging is best-effort */
}
};
// ─── Tasks ───────────────────────────────────────────────────────────────
app.get('/tasks', async (request) => {
const { status, role } = request.query as { status?: string; role?: string };
@ -302,6 +315,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// change). Fires task/updated so a waiting `agenthub work` auto-claims it.
if (patch.assignedTo !== undefined && patch.status === undefined) {
const assigned = assignTask(cwd, id, patch.assignedTo);
logTaskStatus(id, `Addressed to ${assigned.assignedTo}`, assigned.assignedTo);
emitChange(
{
type: 'task',
@ -348,6 +362,17 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled');
}
// Uniformly log the transition for every status-changing caller (claim /
// review / done / cancel / reopen), so the live console tracks progress.
const logText =
task.status === 'in_progress' ? `Claimed by ${task.claimedBy ?? task.assignedTo ?? 'agent'}`
: task.status === 'review' ? `Submitted for review${task.reviewer ? `${task.reviewer}` : ''}`
: task.status === 'done' ? `Approved — done${task.doneBy ? ` by ${task.doneBy}` : ''}`
: task.status === 'cancelled' ? 'Cancelled'
: task.status === 'open' ? 'Reopened'
: `Status → ${task.status}`;
logTaskStatus(id, logText, task.claimedBy ?? task.assignedTo ?? task.reviewer ?? task.doneBy);
emitChange(
{
type: 'task',

View File

@ -3,6 +3,7 @@ import { getTask } from '../core/services/taskService.js';
import { getTaskActivity } from '../core/services/activityService.js';
import { getDecision } from '../core/services/decisionService.js';
import { getHandoff, listHandoffs } from '../core/services/handoffService.js';
import { readTaskLog, type TaskLogEntry } from '../core/services/taskLogService.js';
import { agentAvatar, designTokensCss, escapeHtml, statusPill, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
import type { ActivityItem, Decision, Handoff } from '../core/schema.js';
@ -127,6 +128,13 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
.join('')
: '<div class="empty">No linked decisions for this task.</div>';
const logEntries: TaskLogEntry[] = readTaskLog(cwd, id);
const logLine = (e: TaskLogEntry) =>
`<div class="log-line" data-level="${escapeHtml(e.level ?? 'info')}"><span class="log-ts">${escapeHtml(ago(e.ts))}</span>${e.agent ? `<span class="log-agent">${escapeHtml(e.agent)}</span>` : ''}<span class="log-text">${escapeHtml(e.text)}</span></div>`;
const consoleRows = logEntries.length
? logEntries.map(logLine).join('')
: '<div class="empty" data-empty>No console output yet.</div>';
const activityRows = activity.length
? activity
.map(
@ -178,6 +186,14 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
.kind { color:var(--accent);font:11px/1.4 var(--font-mono); }
.summary { min-width:0;overflow-wrap:anywhere; }
.empty { color:var(--muted); }
.console { max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:2px;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px;font:12px/1.5 var(--font-mono); }
.log-line { display:flex;gap:8px;align-items:baseline;overflow-wrap:anywhere; }
.log-ts { color:var(--muted);white-space:nowrap;flex:0 0 auto; }
.log-agent { color:var(--accent);white-space:nowrap;flex:0 0 auto; }
.log-text { color:var(--text);min-width:0; }
.log-line[data-level="status"] .log-text { color:var(--status-review); }
.log-line[data-level="warn"] .log-text { color:var(--status-review); }
.log-line[data-level="error"] .log-text { color:#F85149; }
@media (max-width:640px){ .activity-row{grid-template-columns:1fr}.actor{white-space:normal} }
</style>
</head>
@ -190,6 +206,10 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
<div class="stats">${taskStats}</div>
${body.trim() ? `<pre>${escapeHtml(body.trim())}</pre>` : ''}
</section>
<section class="panel">
<h2>Live Console</h2>
<div class="console" id="taskConsole">${consoleRows}</div>
</section>
<section class="panel">
<h2>Handoffs</h2>
${handoffRows}
@ -205,6 +225,26 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
</main>
${taskModalHtml()}
${appHeaderJs()}
<script>
(function(){
var TASK_ID = ${JSON.stringify(task.id)};
var box = document.getElementById('taskConsole');
if(!box || !('EventSource' in window)) return;
function fmtAgo(iso){ var t=Date.parse(iso); if(isNaN(t))return''; var s=Math.max(0,Math.floor((Date.now()-t)/1000)); if(s<60)return s+'s'; var m=Math.floor(s/60); if(m<60)return m+'m'; var h=Math.floor(m/60); if(h<24)return h+'h'; return Math.floor(h/24)+'d'; }
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function append(p){
var empty=box.querySelector('[data-empty]'); if(empty) empty.remove();
var el=document.createElement('div'); el.className='log-line'; el.setAttribute('data-level', p.level||'info');
el.innerHTML='<span class="log-ts">'+esc(fmtAgo(p.ts||new Date().toISOString()))+' ago</span>'+(p.agent?'<span class="log-agent">'+esc(p.agent)+'</span>':'')+'<span class="log-text">'+esc(p.text)+'</span>';
box.appendChild(el); box.scrollTop = box.scrollHeight;
}
try {
var s=new EventSource('/events');
s.addEventListener('task-log', function(ev){ try{ var p=JSON.parse(ev.data); if(p && p.taskId===TASK_ID) append(p); }catch(_){} });
window.addEventListener('pagehide', function(){ try{ s.close(); }catch(_){} });
} catch(_){}
})();
</script>
</body>
</html>`;
}

105
tests/taskLog-live.test.ts Normal file
View File

@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { buildApp } from '../src/server/index.js';
import { init } from '../src/cli/commands/init.js';
import { eventBus } from '../src/server/events.js';
import { readTaskLog } from '../src/core/services/taskLogService.js';
import { remoteClient } from '../src/cli/remoteClient.js';
describe('task live console (TSK-0074)', () => {
let cwd: string;
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-tasklog-'));
init(cwd, { projectName: 'tasklog-test', yes: true });
app = buildApp(cwd);
});
afterEach(() => {
eventBus.removeAllListeners('log');
eventBus.removeAllListeners('change');
rmSync(cwd, { recursive: true, force: true });
});
it('PATCH in_progress appends a status log line + emits exactly one task-log event (no double change)', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
const logs: Array<{ taskId?: string; text?: string; level?: string }> = [];
const changes: unknown[] = [];
const onLog = (p: { taskId?: string; text?: string; level?: string }) => logs.push(p);
const onChange = (e: unknown) => changes.push(e);
eventBus.on('log', onLog);
eventBus.on('change', onChange);
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
eventBus.off('log', onLog);
eventBus.off('change', onChange);
// Exactly one task-log SSE event, carrying the task id + a "Claimed by" line.
expect(logs).toHaveLength(1);
expect(logs[0].taskId).toBe('TSK-0001');
expect(logs[0].text).toContain('Claimed by codex');
expect(logs[0].level).toBe('status');
// No double-emission on the change channel (one emitChange per PATCH).
expect(changes).toHaveLength(1);
// Persisted to the task's .log file.
const persisted = readTaskLog(cwd, 'TSK-0001');
expect(persisted.some((e) => e.text.includes('Claimed by codex') && e.level === 'status')).toBe(true);
});
it('logs every status transition (review, done)', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
const texts = readTaskLog(cwd, 'TSK-0001').map((e) => e.text);
expect(texts.some((t) => t.startsWith('Claimed by'))).toBe(true);
expect(texts.some((t) => t.startsWith('Submitted for review'))).toBe(true);
expect(texts.some((t) => t.startsWith('Approved — done'))).toBe(true);
});
it('task detail HTML renders the Live Console with historic lines', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'wrote failing test', agent: 'codex', level: 'info' } });
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001', headers: { accept: 'text/html' } });
expect(res.statusCode).toBe(200);
expect(res.payload).toContain('Live Console');
expect(res.payload).toContain('wrote failing test');
// Live tail wires to the named task-log SSE event, scoped to this task id.
expect(res.payload).toContain("addEventListener('task-log'");
expect(res.payload).toContain('"TSK-0001"');
});
it('POST /tasks/:id/log returns the stored entry', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'green: 12 tests', level: 'status' } });
expect(res.statusCode).toBe(200);
const entry = JSON.parse(res.payload) as { text: string; level: string; ts: string };
expect(entry.text).toBe('green: 12 tests');
expect(entry.level).toBe('status');
expect(entry.ts).toBeTruthy();
});
it('remoteClient.appendTaskLog POSTs to /tasks/:id/log', async () => {
vi.restoreAllMocks();
const stored = { ts: new Date().toISOString(), text: 'progress', level: 'info' };
globalThis.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(stored)),
} as Response);
const res = await remoteClient.appendTaskLog('http://localhost:3377', 'TSK-0001', { text: 'progress', agent: 'codex' });
expect(res.text).toBe('progress');
const call = vi.mocked(fetch).mock.calls[0];
expect(String(call[0])).toBe('http://localhost:3377/tasks/TSK-0001/log');
expect((call[1] as RequestInit).method).toBe('POST');
});
});