fix(work): bereits gehaltene Task wird genannt statt verschwiegen

Vom CEO gefunden: TSK-0293 stand auf in_progress bei codex — und codex tat
nichts. Ursache: `findAddressedOpenTask` sucht ausschliesslich Tasks mit
Status `open`. Haelt ein Agent bereits eine in_progress-Task (typisch nach
einem Session-Neustart), greift der Busy-Guard und der Loop liefert NICHTS
zurueck. Der Agent wartet dann auf neue Arbeit, die nie kommt, waehrend seine
eigene Task unbearbeitet liegt — und von aussen sieht es aus, als sei er tot.

`resolvePending` liefert jetzt `heldTask`, und alle drei Wege sagen es dem
Agenten deutlich:
  - `agenthub start <agent>`: "DU HAELTST BEREITS <id>" ganz oben
  - `agenthub work` (CLI): nennt die Task und beendet sich, statt zu warten
  - `agenthub_work` (MCP): gibt Task samt Body zurueck statt zu blockieren

Tests: 295 → 297.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-29 21:45:15 +02:00
parent 8a1728cbce
commit 908a043ac5
5 changed files with 75 additions and 1 deletions

View File

@ -201,6 +201,11 @@ export async function startAgentSession(options: StartAgentOptions): Promise<str
? ((await remoteClient.getPending(serverUrl, agent)) as ReturnType<typeof resolvePending>) ? ((await remoteClient.getPending(serverUrl, agent)) as ReturnType<typeof resolvePending>)
: resolvePending(projectCwd, agent); : resolvePending(projectCwd, agent);
const lines: string[] = []; const lines: string[] = [];
// Zuerst und am deutlichsten: eine bereits gehaltene Task. Sonst wartet der
// Agent nach einem Neustart auf neue Arbeit, waehrend seine eigene liegt.
if (pending.heldTask) {
lines.push(`⚠️ DU HAELTST BEREITS ${pending.heldTask} — arbeite DORT weiter und reiche sie ein, bevor du Neues anfaengst.`);
}
if (pending.waitingTasks.length) lines.push(`Offen für dich: ${pending.waitingTasks.join(', ')}`); if (pending.waitingTasks.length) lines.push(`Offen für dich: ${pending.waitingTasks.join(', ')}`);
if (pending.awaitingReview.length) lines.push(`Wartet auf Architekten-Review: ${pending.awaitingReview.join(', ')} — du bist so lange gebunden.`); if (pending.awaitingReview.length) lines.push(`Wartet auf Architekten-Review: ${pending.awaitingReview.join(', ')} — du bist so lange gebunden.`);
if (pending.unreadCount) lines.push(`Ungelesene Nachrichten: ${pending.unreadCount}`); if (pending.unreadCount) lines.push(`Ungelesene Nachrichten: ${pending.unreadCount}`);

View File

@ -1,5 +1,6 @@
import { parseSSEBuffer } from './watch.js'; import { parseSSEBuffer } from './watch.js';
import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.js'; import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.js';
import { resolvePending } from '../../core/services/checkinService.js';
import { discoverServer as discoverHubServer } from '../../discovery.js'; import { discoverServer as discoverHubServer } from '../../discovery.js';
import { remoteClient } from '../remoteClient.js'; import { remoteClient } from '../remoteClient.js';
@ -61,6 +62,21 @@ async function drainAgentMessages(ctx: WorkAgentContext): Promise<number> {
export async function workAgent(ctx: WorkAgentContext): Promise<void> { export async function workAgent(ctx: WorkAgentContext): Promise<void> {
await announceAgent(ctx.serverUrl, ctx.agent, ctx.role); await announceAgent(ctx.serverUrl, ctx.agent, ctx.role);
// Haelt der Agent schon eine Task? Der Finder unten sucht nur `open` — nach
// einem Session-Neustart wartet der Worker deshalb auf neue Arbeit, waehrend
// die eigene, bereits geclaimte Task unbearbeitet liegt. Das war ein echter
// Stillstand: Task auf in_progress, Agent tut nichts, niemand sieht warum.
try {
const held = resolvePending(ctx.projectCwd, ctx.agent).heldTask;
if (held) {
console.log(
`AgentHub: du haeltst bereits ${held}. Arbeite dort weiter und reiche sie mit `
+ `\`agenthub task review ${held}\` ein — erst danach kommt Neues.`,
);
return;
}
} catch { /* ohne Projekt-Kontext weiter wie bisher */ }
// Already-waiting task? // Already-waiting task?
const found = await findAddressedOpenTask(ctx); const found = await findAddressedOpenTask(ctx);
if (found) { if (found) {

View File

@ -49,6 +49,18 @@ export interface PendingForAgent {
}; };
/** Offene Tasks, die an den Agenten adressiert sind (inkl. Reopens). */ /** Offene Tasks, die an den Agenten adressiert sind (inkl. Reopens). */
waitingTasks: string[]; waitingTasks: string[];
/**
* Die Task, die der Agent GERADE HAELT (in_progress, von ihm geclaimt).
*
* Klingt trivial, war aber eine echte Sackgasse: der Work-Loop sucht nur
* Tasks mit Status `open`. Haelt ein Agent bereits eine in_progress-Task
* etwa nach einem Session-Neustart , greift der Busy-Guard und der Loop
* liefert NICHTS zurueck. Der Agent wartet dann auf neue Arbeit, die nie
* kommt, waehrend seine eigene Task unbearbeitet liegt. Deshalb muss der
* gehaltene Task ueberall auftauchen, wo ein Agent nach "was ist zu tun"
* fragt.
*/
heldTask?: string;
/** Vom Agenten eingereichte Tasks, die auf das Architekten-Review warten. */ /** Vom Agenten eingereichte Tasks, die auf das Architekten-Review warten. */
awaitingReview: string[]; awaitingReview: string[];
/** /**
@ -130,7 +142,8 @@ export function resolvePending(
isMine(task.assignedTo); isMine(task.assignedTo);
for (const task of tasks) { for (const task of tasks) {
if (task.status === 'open' && addressedToMe(task)) pending.waitingTasks.push(task.id); if (task.status === 'in_progress' && isMine(task.claimedBy ?? task.assignedTo)) pending.heldTask = task.id;
else if (task.status === 'open' && addressedToMe(task)) pending.waitingTasks.push(task.id);
else if (task.status === 'review' && isMine(task.claimedBy ?? task.assignedTo)) pending.awaitingReview.push(task.id); else if (task.status === 'review' && isMine(task.claimedBy ?? task.assignedTo)) pending.awaitingReview.push(task.id);
} }
@ -174,6 +187,9 @@ export function resolvePending(
const parts: string[] = []; const parts: string[] = [];
if (pending.interrupted) parts.push(pending.interrupted.action); if (pending.interrupted) parts.push(pending.interrupted.action);
if (pending.heldTask && !pending.interrupted) {
parts.push(`Du haeltst bereits ${pending.heldTask} — arbeite dort weiter und reiche sie ein, bevor du Neues anfaengst.`);
}
if (pending.unreadCount > 0) parts.push(`${pending.unreadCount} unbeantwortete Nachricht(en) — agenthub_inbox lesen, beantworten und mit agenthub_message quittieren.`); if (pending.unreadCount > 0) parts.push(`${pending.unreadCount} unbeantwortete Nachricht(en) — agenthub_inbox lesen, beantworten und mit agenthub_message quittieren.`);
if (pending.answeredAsks.length > 0) parts.push(`Deine Frage(n) ${pending.answeredAsks.map((a) => a.id).join(', ')} sind BEANTWORTET — lies die Antwort, bevor du weiterbaust.`); if (pending.answeredAsks.length > 0) parts.push(`Deine Frage(n) ${pending.answeredAsks.map((a) => a.id).join(', ')} sind BEANTWORTET — lies die Antwort, bevor du weiterbaust.`);
if (pending.waitingTasks.length > 0) parts.push(`Offen für dich: ${pending.waitingTasks.join(', ')}.`); if (pending.waitingTasks.length > 0) parts.push(`Offen für dich: ${pending.waitingTasks.join(', ')}.`);
@ -186,6 +202,7 @@ export function resolvePending(
export function hasPending(pending: PendingForAgent): boolean { export function hasPending(pending: PendingForAgent): boolean {
return ( return (
pending.interrupted !== undefined || pending.interrupted !== undefined ||
pending.heldTask !== undefined ||
pending.unreadCount > 0 || pending.unreadCount > 0 ||
pending.answeredAsks.length > 0 || pending.answeredAsks.length > 0 ||
pending.waitingTasks.length > 0 pending.waitingTasks.length > 0

View File

@ -311,6 +311,21 @@ export async function startMcpServer(cwd: string, options: { agent?: string } =
LOOP += ' UNATTENDED MODE: never pause for human input — when you need a decision, ' + LOOP += ' UNATTENDED MODE: never pause for human input — when you need a decision, ' +
'call agenthub_ask (it routes to the architect) and await the answer instead of stalling.'; 'call agenthub_ask (it routes to the architect) and await the answer instead of stalling.';
} }
// Haelt der Agent bereits eine Task, wartet der Finder ins Leere (er sucht
// nur `open`). Das nennen wir jetzt beim Namen, statt ihn blockieren zu
// lassen — sonst wartet er auf Arbeit, die er laengst hat.
if (!reviewer) {
const held = resolvePending(root, agent).heldTask;
if (held) {
const detail = remote ? await remoteClient.getTask(serverUrl!, held) : getTask(root, held);
return asText({
held,
body: (detail as { body?: string }).body,
note: `Du haeltst bereits ${held}. Arbeite dort weiter und reiche sie mit agenthub_task_review ein — erst danach bekommst du Neues.`,
loop: LOOP,
});
}
}
const immediate = await finder(); const immediate = await finder();
if (immediate) { if (immediate) {
await leave('work delivered'); await leave('work delivered');

View File

@ -188,3 +188,24 @@ describe('resolvePending — Adressierung wie im Work-Loop', () => {
expect(resolvePending(cwd, 'codex').waitingTasks).toHaveLength(0); expect(resolvePending(cwd, 'codex').waitingTasks).toHaveLength(0);
}); });
}); });
describe('resolvePending — bereits gehaltene Task', () => {
it('nennt die in_progress-Task, die der Agent haelt', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
const pending = resolvePending(cwd, 'codex');
// Der Work-Loop sucht nur `open` — ohne diesen Hinweis wartet ein Agent
// nach dem Neustart auf neue Arbeit, waehrend seine eigene liegt.
expect(pending.heldTask).toBe(id);
expect(pending.note).toMatch(/haeltst bereits/i);
expect(hasPending(pending)).toBe(true);
});
it('meldet keine fremde in_progress-Task als gehalten', () => {
const id = openTaskFor('kimi');
claimTask(cwd, id, 'kimi');
expect(resolvePending(cwd, 'codex').heldTask).toBeUndefined();
});
});