Compare commits
No commits in common. "df698c9dff32b9a995023170f1ccdb8835861a93" and "c9e240b068ff2cf2a732e3ed852c5f60a8a0be01" have entirely different histories.
df698c9dff
...
c9e240b068
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "0.10.2",
|
"version": "0.10.0",
|
||||||
"description": "Local coordination layer for AI coding agents",
|
"description": "Local coordination layer for AI coding agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@ -2,20 +2,15 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { findProjectRoot } from '../../core/paths.js';
|
import { findProjectRoot } from '../../core/paths.js';
|
||||||
import { agentBriefing } from './startAgent.js';
|
|
||||||
import { workAgent } from './work.js';
|
|
||||||
import type { AgentContext } from './start.js';
|
|
||||||
import { remoteClient } from '../remoteClient.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-start: make an agent enter the agenthub work-loop the moment its CLI
|
* Auto-start: make an agent enter the agenthub work-loop the moment its CLI
|
||||||
* session starts — no manual "call agenthub_work" needed.
|
* session starts — no manual "call agenthub_work" needed.
|
||||||
*
|
*
|
||||||
* Every supported CLI exposes a `SessionStart` lifecycle hook whose command runs
|
* Every supported CLI exposes a `SessionStart` lifecycle hook whose command runs
|
||||||
* `agenthub hook-context`, which prints the work-loop instruction. Codex also
|
* `agenthub hook-context`, which prints the work-loop instruction. That text is
|
||||||
* gets a `Stop` hook that waits in `agenthub work` and uses Codex's native
|
* injected into the new session as context. We write the hook to the RIGHT place
|
||||||
* continuation decision to wake the same thread. We write hooks to the RIGHT
|
* per CLI:
|
||||||
* place per CLI:
|
|
||||||
* - Claude Code → `.claude/settings.json` (project, or ~/ with --user)
|
* - Claude Code → `.claude/settings.json` (project, or ~/ with --user)
|
||||||
* - Codex → `~/.codex/config.toml` (global; TUI SessionStart hook)
|
* - Codex → `~/.codex/config.toml` (global; TUI SessionStart hook)
|
||||||
* - Kimi Code → `~/.kimi-code/config.toml` (global; SessionStart hook)
|
* - Kimi Code → `~/.kimi-code/config.toml` (global; SessionStart hook)
|
||||||
@ -42,86 +37,16 @@ export function inferCli(agent: string, explicit?: string): AgentCli {
|
|||||||
/** Printed by the SessionStart hook → injected as session context. */
|
/** Printed by the SessionStart hook → injected as session context. */
|
||||||
export function hookContext(opts: { agent: string; role: string }): void {
|
export function hookContext(opts: { agent: string; role: string }): void {
|
||||||
const { agent, role } = opts;
|
const { agent, role } = opts;
|
||||||
// Denselben Text wie `agenthub start` ausgeben. Der Hook ist fuer viele
|
process.stdout.write(
|
||||||
// Sessions der EINZIGE Einstieg (Codex/Kimi starten automatisch) — ein
|
`AgentHub auto-start — you are "${agent}" (${role}). Before anything else, enter the work loop:\n` +
|
||||||
// eigener, aelterer Text hier fuehrt dazu, dass auto-gestartete Agenten den
|
` agenthub work --agent ${agent} --role ${role}\n` +
|
||||||
// Check-in-Kanal und DEC-0035 gar nicht kennen. Genau das war der Fall, bis
|
`(or call the agenthub_work tool with { agent: "${agent}", role: "${role}" }). It BLOCKS until a ` +
|
||||||
// der CEO bemerkte, dass codex ohne `agenthub start` losgelaufen ist.
|
`task or message addressed to you arrives, then returns it. Handle it, then IMMEDIATELY call ` +
|
||||||
process.stdout.write(`${agentBriefing(agent, role)}\n`);
|
`agenthub_work AGAIN — run it in the BACKGROUND so you're woken on the next event without ` +
|
||||||
}
|
`tying up your turn. The loop is: work -> handle -> agenthub_task_review -> work, forever. ` +
|
||||||
|
`agenthub_work claims your tasks AND delivers your messages. Never run agenthub_task_done. ` +
|
||||||
/**
|
`Staying in this loop is what keeps you reachable — do not end a turn without relaunching agenthub_work.\n`,
|
||||||
* Codex-native Wake-Adapter. A host-managed `Stop` hook can continue the same
|
);
|
||||||
* thread with `decision:block`, so it may safely wait in `agenthub work`
|
|
||||||
* without relying on a detached PTY or MCP logging to wake the model.
|
|
||||||
*/
|
|
||||||
export async function codexStopHook(ctx: AgentContext): Promise<void> {
|
|
||||||
const lines: string[] = [];
|
|
||||||
let failed = false;
|
|
||||||
const originalLog = console.log;
|
|
||||||
const originalError = console.error;
|
|
||||||
const capture = (...args: unknown[]) => {
|
|
||||||
lines.push(args.map((v) => typeof v === 'string' ? v : JSON.stringify(v)).join(' '));
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log = capture;
|
|
||||||
console.error = capture;
|
|
||||||
try {
|
|
||||||
if (ctx.role.toLowerCase() === 'architect') await waitForArchitectChange(ctx);
|
|
||||||
else await workAgent(ctx);
|
|
||||||
} catch (err) {
|
|
||||||
failed = true;
|
|
||||||
capture(`AgentHub: Stop-Hook fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
} finally {
|
|
||||||
console.log = originalLog;
|
|
||||||
console.error = originalError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reason = [
|
|
||||||
...lines,
|
|
||||||
'',
|
|
||||||
'AgentHub hat dich host-nativ geweckt. Bearbeite die gelieferte Task/Nachricht jetzt.',
|
|
||||||
'Wenn dieser Zug endet, armiert der Stop-Hook den Work-Loop automatisch erneut.',
|
|
||||||
].join('\n').trim();
|
|
||||||
if (failed || lines.some((line) =>
|
|
||||||
line.includes('and no server to wait on') || line.includes('no server available for architect')
|
|
||||||
)) {
|
|
||||||
process.stdout.write(`${JSON.stringify({ continue: false, stopReason: reason })}\n`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForArchitectChange(ctx: AgentContext): Promise<void> {
|
|
||||||
if (!ctx.serverUrl) {
|
|
||||||
console.log(`AgentHub: no server available for architect ${ctx.agent}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pulse = await remoteClient.getArchitectPulse(ctx.serverUrl);
|
|
||||||
let seq = pulse.nextSeq; // Seed only: do not replay stale historical events.
|
|
||||||
console.log(`AgentHub: architect ${ctx.agent} wartet auf Reviews, Nachrichten, Fragen oder Task-Statusänderungen…`);
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const [reviews, messages, asks, nextPulse] = await Promise.all([
|
|
||||||
remoteClient.listTasks(ctx.serverUrl, { status: 'review' }),
|
|
||||||
remoteClient.getInbox(ctx.serverUrl, ctx.agent, true),
|
|
||||||
remoteClient.listAsks(ctx.serverUrl, { status: 'pending' }),
|
|
||||||
remoteClient.getArchitectPulse(ctx.serverUrl, seq),
|
|
||||||
]);
|
|
||||||
seq = nextPulse.nextSeq;
|
|
||||||
const taskEvents = nextPulse.events.filter((event) => event.type === 'task');
|
|
||||||
if (reviews.length || messages.length || asks.length || taskEvents.length) {
|
|
||||||
if (reviews.length) console.log(`Reviews: ${reviews.map((task) => task.id).join(', ')}`);
|
|
||||||
for (const message of messages) console.log(`Nachricht ${message.id} von ${message.from}: ${message.text}`);
|
|
||||||
for (const ask of asks) console.log(`Frage ${ask.id} von ${ask.from}: ${ask.question}`);
|
|
||||||
for (const event of taskEvents) {
|
|
||||||
console.log(`Task-Status: ${event.id} → ${event.status ?? event.action}${event.assignedTo ? ` @${event.assignedTo}` : ''}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Marker-delimited upsert (for line-based TOML configs) ───────────────────
|
// ─── Marker-delimited upsert (for line-based TOML configs) ───────────────────
|
||||||
@ -185,36 +110,21 @@ function setupClaude(cwd: string, opts: { agent: string; role: string; user?: bo
|
|||||||
out(' Note: SessionStart context is a strong nudge, not a hard guarantee.');
|
out(' Note: SessionStart context is a strong nudge, not a hard guarantee.');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function codexHookBlock(agent: string, role: string): string {
|
function setupCodex(opts: { agent: string; role: string }, out: (s: string) => void): void {
|
||||||
// Deliberately omit --role: the active role is resolved from AgentHub on
|
const path = join(homedir(), '.codex', 'config.toml');
|
||||||
// every hook run, so switching codex implementer ↔ architect takes effect
|
const command = `agenthub hook-context --agent ${opts.agent} --role ${opts.role}`;
|
||||||
// in the next continuation without reinstalling the hook.
|
const block =
|
||||||
const startCommand = `agenthub hook-context --agent ${agent}`;
|
|
||||||
const stopCommand = `agenthub hook-stop --agent ${agent}`;
|
|
||||||
return (
|
|
||||||
`[[hooks.SessionStart]]\n` +
|
`[[hooks.SessionStart]]\n` +
|
||||||
`matcher = "startup|resume"\n\n` +
|
`matcher = "startup|resume"\n\n` +
|
||||||
`[[hooks.SessionStart.hooks]]\n` +
|
`[[hooks.SessionStart.hooks]]\n` +
|
||||||
`type = "command"\n` +
|
`type = "command"\n` +
|
||||||
`command = ${JSON.stringify(startCommand)}\n` +
|
`command = ${JSON.stringify(command)}\n` +
|
||||||
`timeout = 30\n` +
|
`timeout = 30\n` +
|
||||||
`statusMessage = "AgentHub auto-start (${agent})"\n\n` +
|
`statusMessage = "AgentHub auto-start (${opts.agent})"`;
|
||||||
`[[hooks.Stop]]\n\n` +
|
|
||||||
`[[hooks.Stop.hooks]]\n` +
|
|
||||||
`type = "command"\n` +
|
|
||||||
`command = ${JSON.stringify(stopCommand)}\n` +
|
|
||||||
`timeout = 86400\n` +
|
|
||||||
`statusMessage = "AgentHub: warte auf Arbeit für ${agent}"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupCodex(opts: { agent: string; role: string }, out: (s: string) => void): void {
|
|
||||||
const path = join(homedir(), '.codex', 'config.toml');
|
|
||||||
const block = codexHookBlock(opts.agent, opts.role);
|
|
||||||
upsertMarkerBlock(path, block);
|
upsertMarkerBlock(path, block);
|
||||||
out(`AgentHub: Codex auto-start hook written -> ${path}`);
|
out(`AgentHub: Codex auto-start hook written -> ${path}`);
|
||||||
out(` New Codex TUI sessions start as "${opts.agent}" (${opts.role}).`);
|
out(` New Codex TUI sessions start as "${opts.agent}" (${opts.role}) and enter the work loop.`);
|
||||||
out(' The Stop hook waits in agenthub work and resumes this thread natively when work arrives.');
|
out(' Codex fires SessionStart hooks from the global config in interactive sessions.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupKimi(opts: { agent: string; role: string }, out: (s: string) => void): void {
|
function setupKimi(opts: { agent: string; role: string }, out: (s: string) => void): void {
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
import type { HealthReport } from '../../core/services/presenceService.js';
|
|
||||||
|
|
||||||
/** `agenthub health` — compact hub status + per-agent traffic light. */
|
|
||||||
|
|
||||||
const LIGHT: Record<string, string> = {
|
|
||||||
active: '●', // green — acted <2min ago
|
|
||||||
busy: '●', // blue — working a task
|
|
||||||
idle: '○', // gray — nothing in flight
|
|
||||||
stale: '●', // red — >10min silent with an open assignment
|
|
||||||
};
|
|
||||||
|
|
||||||
function fmtDuration(sec: number): string {
|
|
||||||
if (sec < 60) return `${sec}s`;
|
|
||||||
const m = Math.floor(sec / 60);
|
|
||||||
if (m < 60) return `${m}m`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 48) return `${h}h`;
|
|
||||||
return `${Math.floor(h / 24)}d`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtAgo(sec: number | undefined): string {
|
|
||||||
return sec === undefined ? 'never seen' : `${fmtDuration(sec)} ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function printHealth(h: HealthReport): void {
|
|
||||||
const c = h.counts;
|
|
||||||
console.log(
|
|
||||||
`hub: ${h.status} · v${h.version} · up ${fmtDuration(h.uptimeSec)} · ` +
|
|
||||||
`${c.tasks} tasks (${c.open} open, ${c.inProgress} in progress, ${c.review} review) · ${c.unreadMessages} unread`,
|
|
||||||
);
|
|
||||||
for (const error of h.indexErrors ?? []) {
|
|
||||||
console.log(`! index error · ${error.filePath} · ${error.error}`);
|
|
||||||
}
|
|
||||||
if (h.agents.length === 0) {
|
|
||||||
console.log('no agents yet');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const width = Math.max(...h.agents.map((a) => a.name.length));
|
|
||||||
for (const a of h.agents) {
|
|
||||||
const light = LIGHT[a.state] ?? '○';
|
|
||||||
const task = a.taskId ? ` ${a.taskId}${a.state === 'stale' ? ' open' : ''} ·` : '';
|
|
||||||
const loop = a.inLoop
|
|
||||||
? ` · in loop ${fmtAgo(a.loopSinceAgoSec)}`
|
|
||||||
: a.loopExitReason
|
|
||||||
? ` · out of loop ${fmtAgo(a.loopExitAgoSec)} (${a.loopExitReason})`
|
|
||||||
: ' · loop unknown';
|
|
||||||
console.log(`${a.name.padEnd(width)} ${light} ${a.state}${task} ${fmtAgo(a.lastSeenAgoSec)}${loop}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { startServer } from '../../server/index.js';
|
import { startServer } from '../../server/index.js';
|
||||||
import { findProjectRoot } from '../../core/paths.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Probe a URL to check whether an AgentHub server is already answering there.
|
* Probe a URL to check whether an AgentHub server is already answering there.
|
||||||
@ -31,22 +30,5 @@ export async function serverStart(cwd: string, options: { port: number; host: st
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verzeichnis-Guard: NICHT den Port binden, wenn hier gar kein Projekt liegt.
|
await startServer(cwd, options);
|
||||||
// Ohne diese Prüfung bindet der Server erst den Port, stolpert dann beim
|
|
||||||
// Laden der Config und stirbt — der Port ist kurz belegt, der alte Hub ist
|
|
||||||
// schon gekillt, und die Agenten laufen ins Leere. Klassischer Fall: aus dem
|
|
||||||
// agenthub-Quellrepo statt aus dem Projekt gestartet (mir an einem Tag
|
|
||||||
// dreimal passiert). Lieber sofort und verständlich scheitern.
|
|
||||||
const projectRoot = findProjectRoot(cwd);
|
|
||||||
if (!projectRoot) {
|
|
||||||
console.error(
|
|
||||||
`Kein AgentHub-Projekt in ${cwd} (und keinem übergeordneten Verzeichnis).\n` +
|
|
||||||
`Starte den Server aus dem Projekt-Root — dort liegt .agenthub/ mit den Daten.\n` +
|
|
||||||
`Falls das hier ein neues Projekt werden soll: agenthub init`,
|
|
||||||
);
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await startServer(projectRoot, options);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import {
|
|||||||
claimTask as svcClaimTask,
|
claimTask as svcClaimTask,
|
||||||
} from '../../core/services/taskService.js';
|
} from '../../core/services/taskService.js';
|
||||||
import { listHandoffs as svcListHandoffs, getHandoff as svcGetHandoff } from '../../core/services/handoffService.js';
|
import { listHandoffs as svcListHandoffs, getHandoff as svcGetHandoff } from '../../core/services/handoffService.js';
|
||||||
import { agentIdentity } from '../../core/services/identityService.js';
|
|
||||||
|
|
||||||
export interface AgentContext {
|
export interface AgentContext {
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
@ -22,8 +21,6 @@ interface Listed {
|
|||||||
taskId?: string;
|
taskId?: string;
|
||||||
toAgent?: string;
|
toAgent?: string;
|
||||||
assignedTo?: string;
|
assignedTo?: string;
|
||||||
claimedBy?: string;
|
|
||||||
createdAt?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Announce presence (best-effort) and print the joined line. */
|
/** Announce presence (best-effort) and print the joined line. */
|
||||||
@ -61,88 +58,34 @@ export async function listReviewTasks(ctx: AgentContext): Promise<Listed[]> {
|
|||||||
: svcListTasks(ctx.projectCwd, { status: 'review' });
|
: svcListTasks(ctx.projectCwd, { status: 'review' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_RANK: Record<string, number> = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the ONE open task this agent should claim next. "Addressed" = task
|
* Find the open task addressed to this agent. "Addressed" = task title starts
|
||||||
* title starts with "<agent>:" (delegation convention), OR a handoff for the
|
* with "<agent>:" (delegation convention), OR a handoff for the task has
|
||||||
* task has toAgent === <agent>, OR the task is already assignedTo this agent
|
* toAgent === <agent>, OR the task is already assignedTo this agent (a reopened
|
||||||
* (a reopened task that came back for rework).
|
* task that came back for rework). Returns the task + the handoff list (so the
|
||||||
*
|
* caller can print the matching handoff without re-fetching).
|
||||||
* Single-claim semantics (TSK-0237): while the agent already holds an
|
|
||||||
* in_progress task, NOTHING is addressed — the next auto-claim happens only
|
|
||||||
* after that task reaches review/done (or the claim is taken back via
|
|
||||||
* reopen). Among several addressed candidates exactly one wins: highest
|
|
||||||
* priority, oldest createdAt breaks ties. The rest stays open/assigned.
|
|
||||||
* Returns the task + the handoff list (so the caller can print the matching
|
|
||||||
* handoff without re-fetching).
|
|
||||||
*/
|
*/
|
||||||
export async function findAddressedOpenTask(
|
export async function findAddressedOpenTask(
|
||||||
ctx: AgentContext,
|
ctx: AgentContext,
|
||||||
): Promise<{ task: Listed; handoffs: Listed[] } | undefined> {
|
): Promise<{ task: Listed; handoffs: Listed[] } | undefined> {
|
||||||
const identity = ctx.serverUrl
|
const a = ctx.agent.toLowerCase();
|
||||||
? await remoteClient.getAgentIdentity(ctx.serverUrl, ctx.agent).then((value) => ({
|
|
||||||
canonical: value.canonical,
|
|
||||||
names: new Set(value.names),
|
|
||||||
}))
|
|
||||||
: agentIdentity(ctx.projectCwd, ctx.agent);
|
|
||||||
const a = identity.canonical.toLowerCase();
|
|
||||||
const matchesAgent = (value: unknown) => identity.names.has(String(value ?? '').toLowerCase());
|
|
||||||
|
|
||||||
// DEC-0035 / TSK-0273: `review` zählt als GEHALTEN, nicht nur `in_progress`.
|
|
||||||
// Sonst gilt ein Agent in der Sekunde des Einreichens als frei und greift
|
|
||||||
// sich die nächste Task — weist der Architekt die Review danach zurück,
|
|
||||||
// blockiert der Ein-Task-Guard den Reopen und die zurückgewiesene Arbeit
|
|
||||||
// bleibt unbemerkt liegen (so ging der Reopen von TSK-0218 verloren).
|
|
||||||
// Der Loop bleibt trotzdem aktiv: der Agent wartet WACH auf das Approve,
|
|
||||||
// er fängt nur nichts Neues an.
|
|
||||||
const [inProgress, inReview] = await Promise.all([
|
|
||||||
ctx.serverUrl
|
|
||||||
? remoteClient.listTasks(ctx.serverUrl, { status: 'in_progress' })
|
|
||||||
: Promise.resolve(svcListTasks(ctx.projectCwd, { status: 'in_progress' })),
|
|
||||||
ctx.serverUrl
|
|
||||||
? remoteClient.listTasks(ctx.serverUrl, { status: 'review' })
|
|
||||||
: Promise.resolve(svcListTasks(ctx.projectCwd, { status: 'review' })),
|
|
||||||
]);
|
|
||||||
const busy = [...inProgress, ...inReview].some((t) => matchesAgent(t.claimedBy ?? t.assignedTo));
|
|
||||||
if (busy) return undefined;
|
|
||||||
|
|
||||||
const tasks = await listOpenRoleTasks(ctx);
|
const tasks = await listOpenRoleTasks(ctx);
|
||||||
const handoffs = ctx.serverUrl ? await remoteClient.listHandoffs(ctx.serverUrl) : svcListHandoffs(ctx.projectCwd);
|
const handoffs = ctx.serverUrl ? await remoteClient.listHandoffs(ctx.serverUrl) : svcListHandoffs(ctx.projectCwd);
|
||||||
|
|
||||||
const addressedByHandoff = new Set(
|
const addressedByHandoff = new Set(
|
||||||
handoffs
|
handoffs
|
||||||
.filter((h) => h.toAgent && matchesAgent(h.toAgent) && h.taskId)
|
.filter((h) => h.toAgent && String(h.toAgent).toLowerCase() === a && h.taskId)
|
||||||
.map((h) => String(h.taskId)),
|
.map((h) => String(h.taskId)),
|
||||||
);
|
);
|
||||||
const mine = tasks.filter(
|
const mine = tasks.filter(
|
||||||
(t) =>
|
(t) =>
|
||||||
(t.title ?? '').toLowerCase().startsWith(`${a}:`) ||
|
(t.title ?? '').toLowerCase().startsWith(`${a}:`) ||
|
||||||
addressedByHandoff.has(t.id) ||
|
addressedByHandoff.has(t.id) ||
|
||||||
(t.assignedTo && matchesAgent(t.assignedTo)),
|
(t.assignedTo && String(t.assignedTo).toLowerCase() === a),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mine.length === 0) return undefined;
|
if (mine.length === 0) return undefined;
|
||||||
if (mine.length === 1) return { task: mine[0], handoffs };
|
return { task: mine[0], handoffs };
|
||||||
|
|
||||||
// Priority is not part of the task index — fetch it per candidate (only the
|
|
||||||
// few addressed ones), rank critical > high > medium > low, oldest first.
|
|
||||||
const ranked = await Promise.all(
|
|
||||||
mine.map(async (t) => {
|
|
||||||
let rank = PRIORITY_RANK.medium;
|
|
||||||
try {
|
|
||||||
// Both the remote client and the local service return { task, body }.
|
|
||||||
const detail = ctx.serverUrl ? await remoteClient.getTask(ctx.serverUrl, t.id) : svcGetTask(ctx.projectCwd, t.id);
|
|
||||||
const full = (detail as { task?: { priority?: string } }).task;
|
|
||||||
rank = PRIORITY_RANK[full?.priority ?? 'medium'] ?? PRIORITY_RANK.medium;
|
|
||||||
} catch {
|
|
||||||
/* missing priority ⇒ medium */
|
|
||||||
}
|
|
||||||
return { t, rank };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
ranked.sort((x, y) => y.rank - x.rank || String(x.t.createdAt ?? '').localeCompare(String(y.t.createdAt ?? '')));
|
|
||||||
return { task: ranked[0].t, handoffs };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Claim the task and print its body + handoff + the review-gate next step. */
|
/** Claim the task and print its body + handoff + the review-gate next step. */
|
||||||
@ -161,23 +104,11 @@ export async function claimAndPrintTask(ctx: AgentContext, task: Listed, handoff
|
|||||||
/* body is optional */
|
/* body is optional */
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick the LATEST handoff for this task, not the first one created. When a
|
const hof = handoffs.find((h) => h.taskId === task.id);
|
||||||
// task is reopened with a corrective handoff, the newer handoff supersedes
|
|
||||||
// the older; showing the stale first-created one made agents rebuild against
|
|
||||||
// an outdated spec — the architect's correction never actually reached them.
|
|
||||||
// Handoff ids are zero-padded (HOF-0061 > HOF-0056), so a descending string
|
|
||||||
// sort yields the newest.
|
|
||||||
const taskHofs = handoffs
|
|
||||||
.filter((h) => h.taskId === task.id)
|
|
||||||
.sort((a, b) => String(b.id).localeCompare(String(a.id)));
|
|
||||||
const hof = taskHofs[0];
|
|
||||||
if (hof) {
|
if (hof) {
|
||||||
try {
|
try {
|
||||||
const hd = ctx.serverUrl ? await remoteClient.getHandoff(ctx.serverUrl, hof.id) : svcGetHandoff(ctx.projectCwd, hof.id);
|
const hd = ctx.serverUrl ? await remoteClient.getHandoff(ctx.serverUrl, hof.id) : svcGetHandoff(ctx.projectCwd, hof.id);
|
||||||
console.log(`\n─ Handoff ${hof.id} ──────────────`);
|
console.log(`\n─ Handoff ${hof.id} ──────────────`);
|
||||||
if (taskHofs.length > 1) {
|
|
||||||
console.log(`(latest of ${taskHofs.length} handoffs — supersedes ${taskHofs.slice(1).map((h) => h.id).join(', ')})`);
|
|
||||||
}
|
|
||||||
console.log(hd.handoff.summary);
|
console.log(hd.handoff.summary);
|
||||||
if (hd.body && hd.body.trim()) console.log(hd.body.trim());
|
if (hd.body && hd.body.trim()) console.log(hd.body.trim());
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -1,252 +0,0 @@
|
|||||||
import { loadConfig } from '../../core/config.js';
|
|
||||||
import { resolveAgentName } from '../../core/services/identityService.js';
|
|
||||||
import { resolvePending } from '../../core/services/checkinService.js';
|
|
||||||
import { remoteClient } from '../remoteClient.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `agenthub start <agent>` — der EINE Befehl, mit dem eine frische
|
|
||||||
* Agenten-Session anfängt.
|
|
||||||
*
|
|
||||||
* Motivation (CEO 2026-07-29): „Die Prompts sind mega lang, ich brauche eine
|
|
||||||
* Version, die ich immer verwenden kann." Statt einen Textblock in jede
|
|
||||||
* Session zu kopieren, druckt dieser Befehl das verbindliche Briefing selbst —
|
|
||||||
* inklusive dessen, was gerade auf den Agenten wartet. Der Mensch tippt eine
|
|
||||||
* Zeile, der Agent liest den Rest hier.
|
|
||||||
*
|
|
||||||
* Absichtlich reiner Text (kein JSON): die Ausgabe landet im Kontext eines
|
|
||||||
* LLM-Agenten und muss als Anweisung lesbar sein.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface StartAgentOptions {
|
|
||||||
serverUrl?: string;
|
|
||||||
projectCwd: string;
|
|
||||||
agent: string;
|
|
||||||
role?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Das verbindliche Briefing. Wird von `agenthub start` UND vom SessionStart-Hook
|
|
||||||
* benutzt — beide MÜSSEN denselben Text liefern, sonst arbeitet ein
|
|
||||||
* auto-gestarteter Agent nach veralteten Regeln. */
|
|
||||||
export function agentBriefing(agent: string, role: string): string {
|
|
||||||
return [
|
|
||||||
`Du bist "${agent}" im AgentHub (Rolle: ${role}).`,
|
|
||||||
'',
|
|
||||||
'SO ARBEITEST DU:',
|
|
||||||
'1. ERSTE AKTION, noch vor allem anderen — starte den Work-Loop als',
|
|
||||||
' HINTERGRUND-TASK DEINER EIGENEN UMGEBUNG (so, dass DU benachrichtigt',
|
|
||||||
' wirst, wenn er endet), NICHT als Tool-Call im Turn:',
|
|
||||||
` agenthub work --agent ${agent} --role ${role}`,
|
|
||||||
' OHNE --timeout: der CLI-Worker wartet dann unbegrenzt und endet erst,',
|
|
||||||
' wenn Arbeit fuer dich da ist. Sein Ende weckt dich — genau darum geht es.',
|
|
||||||
' Ein Tool-Call stirbt mit deinem Turn, ein Hintergrund-Task nicht. Daran',
|
|
||||||
' ist die Erreichbarkeit bisher gescheitert.',
|
|
||||||
' Sobald er dir etwas geliefert hat: bearbeiten UND den Worker neu starten.',
|
|
||||||
` (Notnagel, falls dein Host keine Hintergrund-Tasks kann: das Tool`,
|
|
||||||
` agenthub_work(agent="${agent}", role="${role}") — dann OHNE timeoutSec,`,
|
|
||||||
' die passende Wartezeit steht im Roster und ein explizites Argument',
|
|
||||||
' wuerde sie ueberschreiben.)',
|
|
||||||
'2. Deine Aufgabe steht IMMER im Handoff des Tasks. Lies ihn, bevor du baust.',
|
|
||||||
`3. Logge nach jedem Teilschritt: agenthub_task_log(id, text, agent="${agent}").`,
|
|
||||||
'',
|
|
||||||
'DU WIRST GEPUSHT — auch ohne laufenden Tool-Call:',
|
|
||||||
'Die AgentHub-Bridge schickt dir MCP-Benachrichtigungen, sobald etwas für',
|
|
||||||
'dich anliegt: neue oder zurückgegebene Task, Nachricht, beantwortete Frage,',
|
|
||||||
'Abbruch. Diese Meldungen sind VERBINDLICH — reagiere sofort darauf, statt',
|
|
||||||
'zu warten. Steht dort ein Abbruch oder eine Rückgabe: aufhören, nicht',
|
|
||||||
'einreichen.',
|
|
||||||
'',
|
|
||||||
'ZUSÄTZLICH (Netz, kein Ersatz): die ANTWORT von agenthub_task_log trägt',
|
|
||||||
'einen `pending`-Block, wenn etwas offen ist. Loggen bleibt Pflicht — es ist',
|
|
||||||
'dein zweiter Empfangsweg und macht deinen Fortschritt sichtbar.',
|
|
||||||
'',
|
|
||||||
' - Enthält die Antwort `pending.interrupted`, wurde dir der Task',
|
|
||||||
' zurückgegeben, abgebrochen oder entzogen: SOFORT aufhören, NICHT',
|
|
||||||
' einreichen, `pending.interrupted.action` befolgen.',
|
|
||||||
' - Enthält sie ungelesene Nachrichten: mit agenthub_inbox lesen und antworten.',
|
|
||||||
` - Für lange Strecken ohne Log-Zeile: agenthub_checkin(agent="${agent}", taskId="<dein Task>").`,
|
|
||||||
'',
|
|
||||||
'NACH DEM EINREICHEN (DEC-0035):',
|
|
||||||
'Mit agenthub_task_review bist du GEBUNDEN, bis der Architekt abnimmt oder',
|
|
||||||
'zurückweist. Du bekommst bewusst keine neue Task — das ist kein Fehler.',
|
|
||||||
'Bleib im Loop, du wirst geweckt.',
|
|
||||||
'',
|
|
||||||
'GRENZEN: Du markierst NIE selbst done (das ist das Gate des Architekten).',
|
|
||||||
'Du pushst nie ohne Freigabe. Bei Unklarheit: agenthub_ask statt raten.',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Der Architekt startet anders als ein Implementer: er kann prinzipiell NICHT
|
|
||||||
* in `agenthub_work` blockieren (er ist die interaktive Gegenstelle des
|
|
||||||
* Menschen), also ist Pollen für ihn kein Workaround, sondern der richtige Weg.
|
|
||||||
* Sein Sessionstart ist deshalb ein Lagebericht, kein Loop-Einstieg.
|
|
||||||
*/
|
|
||||||
function architectBriefing(agent: string): string {
|
|
||||||
return [
|
|
||||||
`Du bist "${agent}" — Architekt im AgentHub.`,
|
|
||||||
'',
|
|
||||||
'DEIN UNTERSCHIED ZU DEN IMPLEMENTERN:',
|
|
||||||
'Du gehst NICHT in agenthub_work. Ein blockierender Loop ist für dich',
|
|
||||||
'unmöglich, weil du gleichzeitig mit dem Menschen sprichst. Stattdessen',
|
|
||||||
'prüfst du den Stand BEI JEDEM ZUG selbst:',
|
|
||||||
' GET /architect/pulse?since=<nextSeq> → Reviews, dormante Agenten,',
|
|
||||||
' neue Nachrichten, Änderungen. Klein genug für jeden Zug.',
|
|
||||||
'',
|
|
||||||
'DEINE GATES:',
|
|
||||||
' - Review/Approve ist DEIN Gate. Du fragst dafür niemanden um Erlaubnis.',
|
|
||||||
' - Push, Release-Builds und Grundsatzentscheidungen gehören dem Menschen.',
|
|
||||||
' - Was auf `review` steht, wartet auf DICH — melde es ungefragt.',
|
|
||||||
' - Agenten mit offener Review sind gebunden (DEC-0035): erst dein done',
|
|
||||||
' oder reopen gibt sie frei.',
|
|
||||||
'',
|
|
||||||
'WENN ETWAS STILL AUSSIEHT:',
|
|
||||||
'Ein Agent, der arbeitet, empfängt nichts — er ist taub bis zu seinem',
|
|
||||||
'nächsten Check-in. "Kein Log seit X" heißt also nicht "tot". Prüfe erst',
|
|
||||||
'das Task-Log, bevor du jemanden für dormant erklärst.',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function architectHealthReport(serverUrl: string | undefined): Promise<string[]> {
|
|
||||||
const out: string[] = [];
|
|
||||||
if (!serverUrl) {
|
|
||||||
out.push('HUB: kein Server erreichbar — lokaler Dateimodus.');
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const health = await remoteClient.getHealth(serverUrl);
|
|
||||||
const c = health.counts;
|
|
||||||
out.push(`HUB: ${health.status} · v${health.version} · seit ${Math.round(health.uptimeSec / 60)} min`);
|
|
||||||
out.push(`BOARD: ${c.open} offen · ${c.inProgress} in Arbeit · ${c.review} in Review`);
|
|
||||||
|
|
||||||
const busy = health.agents.filter((a) => a.state === 'busy');
|
|
||||||
const waiting = health.agents.filter((a) => a.pendingCount > 0 && a.state !== 'busy');
|
|
||||||
if (busy.length) {
|
|
||||||
out.push('IN ARBEIT:');
|
|
||||||
for (const a of busy) {
|
|
||||||
const deaf = a.deafForSec != null ? ` · kein Check-in seit ${Math.round(a.deafForSec / 60)} min` : '';
|
|
||||||
out.push(` ${a.name} → ${a.taskId ?? '?'}${deaf}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (waiting.length) {
|
|
||||||
out.push('WARTET AUF ÜBERNAHME:');
|
|
||||||
for (const a of waiting) out.push(` ${a.name}: ${a.pendingCount} Task(s)`);
|
|
||||||
}
|
|
||||||
if (c.review > 0) out.push(`⚠️ ${c.review} Task(s) warten auf DEIN Review — zuerst erledigen.`);
|
|
||||||
if (!busy.length && !waiting.length && !c.review) out.push('Nichts hängt. Sauberer Start.');
|
|
||||||
} catch {
|
|
||||||
out.push('HUB: nicht erreichbar. Läuft `agenthub server start --host 0.0.0.0` aus dem Projekt-Root?');
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Gibt die aufgelöste Rolle zurück, damit der Aufrufer weiß, ob noch ein
|
|
||||||
* Implementer-Onboarding (Task claimen) folgen soll. */
|
|
||||||
export async function startAgentSession(options: StartAgentOptions): Promise<string> {
|
|
||||||
const { projectCwd, serverUrl } = options;
|
|
||||||
|
|
||||||
let agent = options.agent;
|
|
||||||
let role = options.role;
|
|
||||||
|
|
||||||
// A role name is also a stable session entry point. This lets the human use
|
|
||||||
// `agenthub start architect` without knowing whether Claude, Codex, or a
|
|
||||||
// future agent currently holds that role.
|
|
||||||
if (!role && serverUrl) {
|
|
||||||
try {
|
|
||||||
const roles = await remoteClient.listRoles(serverUrl);
|
|
||||||
const requestedRole = Object.keys(roles).find(
|
|
||||||
(name) => name.toLowerCase() === options.agent.toLowerCase(),
|
|
||||||
);
|
|
||||||
if (requestedRole) {
|
|
||||||
role = requestedRole;
|
|
||||||
agent = roles[requestedRole].preferredAgent;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Hub nicht erreichbar — die lokale Config wird weiter unten versucht.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!role) {
|
|
||||||
try {
|
|
||||||
const config = loadConfig(projectCwd);
|
|
||||||
const requestedRole = Object.keys(config.roles ?? {}).find(
|
|
||||||
(name) => name.toLowerCase() === options.agent.toLowerCase(),
|
|
||||||
);
|
|
||||||
if (requestedRole) {
|
|
||||||
role = requestedRole;
|
|
||||||
agent = config.roles[requestedRole].preferredAgent;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keine lokale Config — normale Agent-Auflösung läuft weiter.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rolle IMMER aus dem Roster des Hubs, wenn einer erreichbar ist: der Server
|
|
||||||
// hält die Wahrheit. Der lokale Weg greift nur ohne Hub — sonst hängt das
|
|
||||||
// Ergebnis davon ab, aus welchem Verzeichnis der Befehl gestartet wurde
|
|
||||||
// (das Quellrepo hat z. B. ein .agenthub OHNE Config, was die lokale
|
|
||||||
// Auflösung stumm auf "implementer" zurückfallen ließ).
|
|
||||||
if (!role && serverUrl) {
|
|
||||||
try {
|
|
||||||
const health = await remoteClient.getHealth(serverUrl);
|
|
||||||
const match = health.agents.find((a) => a.name.toLowerCase() === agent.toLowerCase());
|
|
||||||
if (match) {
|
|
||||||
agent = match.name;
|
|
||||||
role = match.role;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Hub nicht erreichbar — lokaler Weg unten.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!role) {
|
|
||||||
try {
|
|
||||||
agent = resolveAgentName(projectCwd, options.agent);
|
|
||||||
const config = loadConfig(projectCwd);
|
|
||||||
const architect = config.roles?.architect?.preferredAgent;
|
|
||||||
role = agent === architect ? 'architect' : (config.agents?.[agent]?.role ?? 'implementer');
|
|
||||||
} catch {
|
|
||||||
role = 'implementer';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (serverUrl) {
|
|
||||||
try {
|
|
||||||
await remoteClient.announce(serverUrl, agent, role);
|
|
||||||
} catch {
|
|
||||||
// Präsenz ist best-effort — ein nicht erreichbarer Hub darf den Start
|
|
||||||
// nicht verhindern, das Briefing gilt trotzdem.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Architekt: Lagebericht statt Loop-Einstieg.
|
|
||||||
if (role === 'architect') {
|
|
||||||
console.log(architectBriefing(agent));
|
|
||||||
console.log('');
|
|
||||||
for (const line of await architectHealthReport(serverUrl)) console.log(line);
|
|
||||||
return 'architect';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(agentBriefing(agent, role ?? "implementer"));
|
|
||||||
|
|
||||||
// Was liegt gerade an? Damit die Session nicht blind in den Loop geht.
|
|
||||||
try {
|
|
||||||
const pending = serverUrl
|
|
||||||
? ((await remoteClient.getPending(serverUrl, agent)) as ReturnType<typeof resolvePending>)
|
|
||||||
: resolvePending(projectCwd, agent);
|
|
||||||
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.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}`);
|
|
||||||
console.log('');
|
|
||||||
console.log(lines.length ? `AKTUELL:\n ${lines.join('\n ')}` : 'AKTUELL: nichts offen — geh in den Loop und warte.');
|
|
||||||
} catch {
|
|
||||||
console.log('');
|
|
||||||
console.log('AKTUELL: Status nicht abrufbar — geh trotzdem in den Loop.');
|
|
||||||
}
|
|
||||||
return role ?? 'implementer';
|
|
||||||
}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { input, select } from '@inquirer/prompts';
|
import { input, select } from '@inquirer/prompts';
|
||||||
import { createTask, recordExternalTask, dispatchTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js';
|
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js';
|
||||||
import { appendTaskLog } from '../../core/services/taskLogService.js';
|
import { appendTaskLog } from '../../core/services/taskLogService.js';
|
||||||
import type { Task } from '../../core/schema.js';
|
import type { Task } from '../../core/schema.js';
|
||||||
|
|
||||||
@ -42,14 +42,6 @@ export function taskClaim(cwd: string, id: string, agentName: string): void {
|
|||||||
claimTask(cwd, id, agentName);
|
claimTask(cwd, id, agentName);
|
||||||
console.log(`AgentHub: Task claimed ${id} by ${agentName}`);
|
console.log(`AgentHub: Task claimed ${id} by ${agentName}`);
|
||||||
}
|
}
|
||||||
export function taskDispatch(cwd: string, id: string, agentName: string): void {
|
|
||||||
dispatchTask(cwd, id, agentName);
|
|
||||||
console.log(`AgentHub: Architect started ${agentName} for ${id}`);
|
|
||||||
}
|
|
||||||
export function taskRecord(cwd: string, options: { title: string; doneBy: string; description?: string; role?: Task['role']; priority?: Task['priority'] }): void {
|
|
||||||
const task = recordExternalTask(cwd, options);
|
|
||||||
console.log(`AgentHub: External work recorded ${task.id} by ${task.doneBy}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function taskDone(
|
export function taskDone(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
|
|||||||
@ -24,30 +24,21 @@ export type { AgentHubEvent } from '../../server/events.js';
|
|||||||
* Parse all complete SSE events from a text buffer.
|
* Parse all complete SSE events from a text buffer.
|
||||||
*
|
*
|
||||||
* SSE wire format: "data: <json>\n\n" per event, ": \n\n" for keepalives.
|
* SSE wire format: "data: <json>\n\n" per event, ": \n\n" for keepalives.
|
||||||
* Frame separators are CRLF-tolerant: both "\n\n" and "\r\n\r\n" (and mixed
|
* This function splits on double-newline boundaries, extracts the `data:`
|
||||||
* line endings inside a frame) are accepted. An event with multiple `data:`
|
* line from each complete block, and returns anything that didn't end with
|
||||||
* lines is joined with "\n" before parsing, per the SSE spec.
|
* "\n\n" as `remaining` (to be prepended to the next chunk).
|
||||||
* Anything that didn't end with a blank line is returned as `remaining`
|
|
||||||
* (to be prepended to the next chunk).
|
|
||||||
*/
|
*/
|
||||||
export function parseSSEBuffer(buffer: string): { events: AgentHubEvent[]; remaining: string } {
|
export function parseSSEBuffer(buffer: string): { events: AgentHubEvent[]; remaining: string } {
|
||||||
const parts = buffer.split(/\r?\n\r?\n/);
|
const parts = buffer.split('\n\n');
|
||||||
const remaining = parts.pop() ?? ''; // last segment may be incomplete
|
const remaining = parts.pop() ?? ''; // last segment may be incomplete
|
||||||
const events: AgentHubEvent[] = [];
|
const events: AgentHubEvent[] = [];
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
// A keepalive block looks like ":" — no data line.
|
// A keepalive block looks like ":" — no data line.
|
||||||
const lines = part.split(/\r?\n/);
|
const dataLine = part.split('\n').find((l) => l.startsWith('data: '));
|
||||||
const eventName = lines.find((l) => l.startsWith('event: '))?.slice(7);
|
if (!dataLine) continue;
|
||||||
if (eventName && eventName !== 'message') continue;
|
|
||||||
const idLine = lines.find((l) => l.startsWith('id: '));
|
|
||||||
const seq = idLine ? Number.parseInt(idLine.slice(4), 10) : undefined;
|
|
||||||
const dataLines = lines.filter((l) => l.startsWith('data: ')).map((l) => l.slice(6));
|
|
||||||
if (dataLines.length === 0) continue;
|
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(dataLines.join('\n')) as AgentHubEvent;
|
events.push(JSON.parse(dataLine.slice(6)) as AgentHubEvent);
|
||||||
if (seq !== undefined && Number.isFinite(seq)) event.seq = seq;
|
|
||||||
events.push(event);
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed JSON — should never happen in practice.
|
// Ignore malformed JSON — should never happen in practice.
|
||||||
}
|
}
|
||||||
@ -168,16 +159,10 @@ async function fetchUnreadMessages(serverUrl: string, agent: string): Promise<Ag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMessageFor(event: AgentHubEvent, agent: string, ignoreFrom: string[] = []): boolean {
|
function isMessageFor(event: AgentHubEvent, agent: string): boolean {
|
||||||
if (event.type !== 'message' || event.action !== 'created') return false;
|
if (event.type !== 'message' || event.action !== 'created') return false;
|
||||||
const to = event.assignedTo;
|
const to = event.assignedTo;
|
||||||
if (!to || !messageRecipientAliases(agent).has(String(to).toLowerCase())) return false;
|
return !!to && messageRecipientAliases(agent).has(String(to).toLowerCase());
|
||||||
// Absender ausblenden, die den Notifier nur zumüllen. Konkreter Fall: der
|
|
||||||
// Watchdog schreibt dem Architekten alle paar Minuten Erinnerungen — ohne
|
|
||||||
// Filter weckt der Notifier ihn im Takt dieser Erinnerungen, obwohl nichts
|
|
||||||
// Neues passiert ist, und das Signal entwertet sich selbst.
|
|
||||||
const sender = String(event.from ?? (event.title ?? '').split('→')[0] ?? '').trim().toLowerCase();
|
|
||||||
return !ignoreFrom.some((ignored) => ignored.trim().toLowerCase() === sender);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -194,7 +179,7 @@ function isMessageFor(event: AgentHubEvent, agent: string, ignoreFrom: string[]
|
|||||||
*/
|
*/
|
||||||
export async function watchEvents(
|
export async function watchEvents(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean; ignoreFrom?: string[] } = {},
|
options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean } = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const url = new URL('/events', serverUrl);
|
const url = new URL('/events', serverUrl);
|
||||||
// Pass role to the server for an additional server-side filter (saves
|
// Pass role to the server for an additional server-side filter (saves
|
||||||
@ -202,7 +187,6 @@ export async function watchEvents(
|
|||||||
if (options.role) url.searchParams.set('role', options.role);
|
if (options.role) url.searchParams.set('role', options.role);
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
let lastEventId: number | undefined;
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(url.toString(), {
|
response = await fetch(url.toString(), {
|
||||||
headers: { Accept: 'text/event-stream' },
|
headers: { Accept: 'text/event-stream' },
|
||||||
@ -266,7 +250,6 @@ export async function watchEvents(
|
|||||||
buffer = remaining;
|
buffer = remaining;
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (event.seq !== undefined) lastEventId = event.seq;
|
|
||||||
// Client-side role filter: skip tasks that don't match the requested
|
// Client-side role filter: skip tasks that don't match the requested
|
||||||
// role. Non-task events (handoffs, decisions, memory) always print.
|
// role. Non-task events (handoffs, decisions, memory) always print.
|
||||||
if (options.role && event.type === 'task' && event.role !== undefined && event.role !== options.role) {
|
if (options.role && event.type === 'task' && event.role !== undefined && event.role !== options.role) {
|
||||||
@ -286,7 +269,7 @@ export async function watchEvents(
|
|||||||
await reader.cancel();
|
await reader.cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.awaitMessage && isMessageFor(event, options.awaitMessage, options.ignoreFrom)) {
|
if (options.awaitMessage && isMessageFor(event, options.awaitMessage)) {
|
||||||
await reader.cancel();
|
await reader.cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
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';
|
||||||
|
|
||||||
@ -20,8 +19,6 @@ interface WorkAgentContext extends AgentContext {
|
|||||||
timeoutSec?: number;
|
timeoutSec?: number;
|
||||||
discoverServer?: (timeoutMs?: number) => Promise<string | undefined>;
|
discoverServer?: (timeoutMs?: number) => Promise<string | undefined>;
|
||||||
reconnectBackoffMs?: number[];
|
reconnectBackoffMs?: number[];
|
||||||
/** Polling-fallback interval while the SSE wait is open (default 4000 ms). */
|
|
||||||
pollIntervalMs?: number;
|
|
||||||
/**
|
/**
|
||||||
* Unattended mode (TSK-0118): the agent runs without a human at the keyboard.
|
* Unattended mode (TSK-0118): the agent runs without a human at the keyboard.
|
||||||
* It must never pause for human input — when it needs a decision it routes an
|
* It must never pause for human input — when it needs a decision it routes an
|
||||||
@ -31,17 +28,11 @@ interface WorkAgentContext extends AgentContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch + print the agent's unread messages. Returns how many were surfaced.
|
* Fetch + print + mark-read the agent's unread messages. Returns how many were
|
||||||
* This is what lets the work loop wake on an architect follow-up / question
|
* surfaced. This is what lets the work loop wake on an architect follow-up /
|
||||||
* (TSK-0119): after a `task review` submit the implementer re-arms `work` and
|
* question (TSK-0119): after a `task review` submit the implementer re-arms
|
||||||
* stays reachable — a reopen or a new assignment wakes it via a task event,
|
* `work` and stays reachable — a reopen or a new assignment wakes it via a task
|
||||||
* and a plain message wakes it here instead of leaving it dormant.
|
* event, and a plain message wakes it here instead of leaving it dormant.
|
||||||
*
|
|
||||||
* Surfacing flips each message `unread` → `delivered` (the listInbox read
|
|
||||||
* receipt), but deliberately does NOT mark it `read`: the message stays
|
|
||||||
* visible in the inbox until the agent explicitly acks/reads it, so a
|
|
||||||
* surfaced-but-missed message is never lost. The loop wakes only on `unread`,
|
|
||||||
* so a delivered message never re-wakes it (no spin).
|
|
||||||
*/
|
*/
|
||||||
async function drainAgentMessages(ctx: WorkAgentContext): Promise<number> {
|
async function drainAgentMessages(ctx: WorkAgentContext): Promise<number> {
|
||||||
if (!ctx.serverUrl) return 0;
|
if (!ctx.serverUrl) return 0;
|
||||||
@ -55,6 +46,11 @@ async function drainAgentMessages(ctx: WorkAgentContext): Promise<number> {
|
|||||||
console.log(`AgentHub: ${msgs.length} message${msgs.length === 1 ? '' : 's'} for ${ctx.agent}:`);
|
console.log(`AgentHub: ${msgs.length} message${msgs.length === 1 ? '' : 's'} for ${ctx.agent}:`);
|
||||||
for (const m of msgs) {
|
for (const m of msgs) {
|
||||||
console.log(` ${m.id} ${m.from} → ${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
|
console.log(` ${m.id} ${m.from} → ${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
|
||||||
|
try {
|
||||||
|
await remoteClient.markMessageRead(ctx.serverUrl, m.id);
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return msgs.length;
|
return msgs.length;
|
||||||
}
|
}
|
||||||
@ -62,21 +58,6 @@ 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) {
|
||||||
@ -121,11 +102,9 @@ function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let controller: AbortController | undefined;
|
let controller: AbortController | undefined;
|
||||||
let poll: NodeJS.Timeout | undefined;
|
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
if (poll) clearInterval(poll);
|
|
||||||
try {
|
try {
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
} catch {
|
} catch {
|
||||||
@ -143,74 +122,36 @@ function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
|
|||||||
const deadline = ctx.timeoutSec ? Date.now() + ctx.timeoutSec * 1000 : undefined;
|
const deadline = ctx.timeoutSec ? Date.now() + ctx.timeoutSec * 1000 : undefined;
|
||||||
const backoffs = ctx.reconnectBackoffMs ?? [2000, 5000, 10000];
|
const backoffs = ctx.reconnectBackoffMs ?? [2000, 5000, 10000];
|
||||||
let reconnectAttempt = 0;
|
let reconnectAttempt = 0;
|
||||||
let lastEventId: number | undefined;
|
|
||||||
// Guards against overlapping checks (SSE-triggered vs. polling fallback).
|
|
||||||
let checking = false;
|
|
||||||
|
|
||||||
// Re-query then claim if a task addressed to us is now open. Returns true
|
// Re-query then claim if a task addressed to us is now open. Returns true
|
||||||
// if a task was claimed (so the caller can stop).
|
// if a task was claimed (so the caller can stop).
|
||||||
const tryClaim = async (): Promise<boolean> => {
|
const tryClaim = async (): Promise<boolean> => {
|
||||||
if (checking) return false;
|
ctx.serverUrl = serverUrl;
|
||||||
checking = true;
|
const f = await findAddressedOpenTask(ctx);
|
||||||
try {
|
if (!f) return false;
|
||||||
ctx.serverUrl = serverUrl;
|
if (timer) clearTimeout(timer);
|
||||||
const f = await findAddressedOpenTask(ctx);
|
await claimAndPrintTask(ctx, f.task, f.handoffs);
|
||||||
if (!f) return false;
|
finish();
|
||||||
if (timer) clearTimeout(timer);
|
return true;
|
||||||
await claimAndPrintTask(ctx, f.task, f.handoffs);
|
|
||||||
finish();
|
|
||||||
return true;
|
|
||||||
} finally {
|
|
||||||
checking = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wake on an architect follow-up message (not just tasks): surface it and
|
// Wake on an architect follow-up message (not just tasks): surface it and
|
||||||
// stop, so the implementer never sits dormant on a pending question.
|
// stop, so the implementer never sits dormant on a pending question.
|
||||||
const trySurfaceMessages = async (): Promise<boolean> => {
|
const trySurfaceMessages = async (): Promise<boolean> => {
|
||||||
if (checking) return false;
|
ctx.serverUrl = serverUrl;
|
||||||
checking = true;
|
const n = await drainAgentMessages(ctx);
|
||||||
try {
|
if (n === 0) return false;
|
||||||
ctx.serverUrl = serverUrl;
|
if (timer) clearTimeout(timer);
|
||||||
const n = await drainAgentMessages(ctx);
|
finish();
|
||||||
if (n === 0) return false;
|
return true;
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
finish();
|
|
||||||
return true;
|
|
||||||
} finally {
|
|
||||||
checking = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Polling fallback: SSE is instant when it works, but a dropped frame on an
|
|
||||||
// otherwise-open stream must never mean an infinite sleep — re-check tasks
|
|
||||||
// and messages every few seconds, so a lost event costs at most one
|
|
||||||
// poll interval (~4s by default).
|
|
||||||
poll = setInterval(() => {
|
|
||||||
if (settled) return;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
if (await tryClaim()) return;
|
|
||||||
await trySurfaceMessages();
|
|
||||||
} catch {
|
|
||||||
/* best-effort: the SSE path and the next tick remain */
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, ctx.pollIntervalMs ?? 4000);
|
|
||||||
poll.unref?.();
|
|
||||||
|
|
||||||
const waitLoop = async () => {
|
const waitLoop = async () => {
|
||||||
while (!settled && remainingMs(deadline) > 0) {
|
while (!settled && remainingMs(deadline) > 0) {
|
||||||
controller = new AbortController();
|
controller = new AbortController();
|
||||||
try {
|
try {
|
||||||
ctx.serverUrl = serverUrl;
|
ctx.serverUrl = serverUrl;
|
||||||
const res = await fetch(`${serverUrl}/events`, {
|
const res = await fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } });
|
||||||
signal: controller.signal,
|
|
||||||
headers: {
|
|
||||||
Accept: 'text/event-stream',
|
|
||||||
...(lastEventId !== undefined ? { 'Last-Event-ID': String(lastEventId) } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!res.body) throw new Error('SSE response has no body');
|
if (!res.body) throw new Error('SSE response has no body');
|
||||||
|
|
||||||
// Close the gap: a task or message may have appeared between the initial
|
// Close the gap: a task or message may have appeared between the initial
|
||||||
@ -235,9 +176,6 @@ function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
|
|||||||
|
|
||||||
const { events, remaining } = parseSSEBuffer(buffer);
|
const { events, remaining } = parseSSEBuffer(buffer);
|
||||||
buffer = remaining;
|
buffer = remaining;
|
||||||
for (const event of events) {
|
|
||||||
if (event.seq !== undefined) lastEventId = event.seq;
|
|
||||||
}
|
|
||||||
// Any task event may mean a task addressed to us just opened/reopened.
|
// Any task event may mean a task addressed to us just opened/reopened.
|
||||||
if (events.some((e) => e.type === 'task')) {
|
if (events.some((e) => e.type === 'task')) {
|
||||||
if (await tryClaim()) return;
|
if (await tryClaim()) return;
|
||||||
|
|||||||
235
src/cli/index.ts
235
src/cli/index.ts
@ -2,12 +2,12 @@ import { Command } from 'commander';
|
|||||||
import { init } from './commands/init.js';
|
import { init } from './commands/init.js';
|
||||||
import { status } from './commands/status.js';
|
import { status } from './commands/status.js';
|
||||||
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
|
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
|
||||||
import { taskCreate, taskList, taskShow, taskClaim, taskDispatch, taskRecord, taskDone, taskReview, taskReopen, taskAssign, taskLog } 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 { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
||||||
import { decisionCreate, decisionList } from './commands/decision.js';
|
import { decisionCreate, decisionList } from './commands/decision.js';
|
||||||
import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js';
|
import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js';
|
||||||
import { askCreate, askList, askAnswer, askEscalate, waitForAsk } from './commands/ask.js';
|
import { askCreate, askList, askAnswer, askEscalate, waitForAsk } from './commands/ask.js';
|
||||||
import { agentSetup, codexStopHook, hookContext } from './commands/agentSetup.js';
|
import { agentSetup, hookContext } from './commands/agentSetup.js';
|
||||||
import { syncOrgFromFile } from '../core/services/orgService.js';
|
import { syncOrgFromFile } from '../core/services/orgService.js';
|
||||||
import { delegate } from './commands/delegate.js';
|
import { delegate } from './commands/delegate.js';
|
||||||
import { serverStart } from './commands/server.js';
|
import { serverStart } from './commands/server.js';
|
||||||
@ -15,16 +15,12 @@ import { update } from './commands/update.js';
|
|||||||
import { watchEvents } from './commands/watch.js';
|
import { watchEvents } from './commands/watch.js';
|
||||||
import { startAgent } from './commands/start.js';
|
import { startAgent } from './commands/start.js';
|
||||||
import { workAgent } from './commands/work.js';
|
import { workAgent } from './commands/work.js';
|
||||||
import { startAgentSession } from './commands/startAgent.js';
|
|
||||||
import { startMcpServer } from '../mcp/server.js';
|
import { startMcpServer } from '../mcp/server.js';
|
||||||
import { installMcp } from '../mcp/install.js';
|
import { installMcp } from '../mcp/install.js';
|
||||||
import { loadConfig, saveConfig } from '../core/config.js';
|
import { loadConfig, saveConfig } from '../core/config.js';
|
||||||
import { findProjectRoot } from '../core/paths.js';
|
import { findProjectRoot } from '../core/paths.js';
|
||||||
import { discoverServer, resolveReachableServerUrl } from '../discovery.js';
|
import { discoverServer } from '../discovery.js';
|
||||||
import { remoteClient, RemoteError } from './remoteClient.js';
|
import { remoteClient, RemoteError } from './remoteClient.js';
|
||||||
import { printHealth } from './commands/health.js';
|
|
||||||
import { VERSION } from '../version.js';
|
|
||||||
import { setPreferredAgent } from '../core/services/roleService.js';
|
|
||||||
|
|
||||||
interface ResolvedContext {
|
interface ResolvedContext {
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
@ -51,36 +47,6 @@ interface ResolvedContext {
|
|||||||
// exactly one command per process.
|
// exactly one command per process.
|
||||||
let activeProjectCwd: string | undefined;
|
let activeProjectCwd: string | undefined;
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Läuft der gefundene Hub auf DIESER Maschine, dann Loopback bevorzugen.
|
|
||||||
*
|
|
||||||
* Hintergrund (real erlebt, kostete Stunden): Die Selbstheilung unten sucht
|
|
||||||
* per mDNS, wenn die konfigurierte Adresse kurz nicht antwortet — das passiert
|
|
||||||
* bei JEDEM Hub-Neustart. Sie fand die LAN-IP und schrieb sie in die Config,
|
|
||||||
* womit ein zuvor gesetztes 127.0.0.1 verlorenging. Die LAN-IP wiederum bricht
|
|
||||||
* beim nächsten Netzwechsel oder Neustart weg, also heilte sich das System in
|
|
||||||
* die instabilere Adresse hinein. Loopback ist für Agenten auf derselben
|
|
||||||
* Maschine strikt robuster — es kann weder durch mDNS-Neuankündigung noch
|
|
||||||
* durch einen Netzwechsel ungültig werden.
|
|
||||||
*/
|
|
||||||
async function preferLoopback(url: string | undefined): Promise<string | undefined> {
|
|
||||||
if (!url) return url;
|
|
||||||
let port: string;
|
|
||||||
try {
|
|
||||||
port = new URL(url).port || '3377';
|
|
||||||
} catch {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
const loopback = `http://127.0.0.1:${port}`;
|
|
||||||
if (url === loopback) return url;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${loopback}/status`, { signal: AbortSignal.timeout(1200) });
|
|
||||||
if (res.ok) return loopback;
|
|
||||||
} catch { /* nicht lokal erreichbar — dann bleibt die gefundene Adresse */ }
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveContext(program: Command, cwd: string): Promise<ResolvedContext> {
|
async function resolveContext(program: Command, cwd: string): Promise<ResolvedContext> {
|
||||||
activeProjectCwd = cwd;
|
activeProjectCwd = cwd;
|
||||||
const flag = (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER;
|
const flag = (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER;
|
||||||
@ -95,29 +61,11 @@ async function resolveContext(program: Command, cwd: string): Promise<ResolvedCo
|
|||||||
} catch {
|
} catch {
|
||||||
return { projectCwd: root };
|
return { projectCwd: root };
|
||||||
}
|
}
|
||||||
if (config.serverUrl) {
|
if (config.serverUrl) return { serverUrl: config.serverUrl, projectCwd: root };
|
||||||
// Self-heal a stale saved URL (e.g. agenthub.local after a network
|
|
||||||
// change): probe it with a short timeout; if it's dead, re-discover the
|
|
||||||
// live server, persist it and use it for this call — no hard failure.
|
|
||||||
const reachable = await resolveReachableServerUrl(config.serverUrl);
|
|
||||||
if (reachable === config.serverUrl) return { serverUrl: config.serverUrl, projectCwd: root };
|
|
||||||
if (reachable) {
|
|
||||||
try {
|
|
||||||
saveConfig(root, { ...config, serverUrl: reachable });
|
|
||||||
console.error(`AgentHub: ${config.serverUrl} unreachable — switched to discovered server ${reachable} (config updated).`);
|
|
||||||
} catch {
|
|
||||||
console.error(`AgentHub: ${config.serverUrl} unreachable — using discovered server ${reachable} for this call.`);
|
|
||||||
}
|
|
||||||
return { serverUrl: reachable, projectCwd: root };
|
|
||||||
}
|
|
||||||
// Nothing discovered: keep the configured URL so runRemote reports its
|
|
||||||
// usual friendly "not reachable" error (and re-attempts discovery).
|
|
||||||
return { serverUrl: config.serverUrl, projectCwd: root };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialized project but no server configured yet: auto-find one on the
|
// Initialized project but no server configured yet: auto-find one on the
|
||||||
// LAN and remember it, so the agent connects with zero manual setup.
|
// LAN and remember it, so the agent connects with zero manual setup.
|
||||||
const discovered = await preferLoopback(await discoverServer(2000));
|
const discovered = await discoverServer(2000);
|
||||||
if (discovered) {
|
if (discovered) {
|
||||||
try {
|
try {
|
||||||
saveConfig(root, { ...config, serverUrl: discovered });
|
saveConfig(root, { ...config, serverUrl: discovered });
|
||||||
@ -172,7 +120,7 @@ async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<vo
|
|||||||
export function createProgram(cwd: string): Command {
|
export function createProgram(cwd: string): Command {
|
||||||
const program = new Command('agenthub')
|
const program = new Command('agenthub')
|
||||||
.description('Local coordination layer for AI coding agents')
|
.description('Local coordination layer for AI coding agents')
|
||||||
.version(VERSION)
|
.version('0.9.1')
|
||||||
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
||||||
|
|
||||||
program
|
program
|
||||||
@ -201,21 +149,6 @@ export function createProgram(cwd: string): Command {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
|
||||||
.command('health')
|
|
||||||
.description('Hub health: status, uptime and per-agent traffic lights')
|
|
||||||
.action(async () => {
|
|
||||||
const { serverUrl } = await resolveContext(program, cwd);
|
|
||||||
if (!serverUrl) {
|
|
||||||
console.error('agenthub health needs a running server. Start one with: agenthub server start --host 0.0.0.0');
|
|
||||||
process.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await runRemote(serverUrl, async () => {
|
|
||||||
printHealth(await remoteClient.getHealth(serverUrl));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const memoryCmd = new Command('memory').description('Manage memory entries');
|
const memoryCmd = new Command('memory').description('Manage memory entries');
|
||||||
memoryCmd
|
memoryCmd
|
||||||
.command('add')
|
.command('add')
|
||||||
@ -368,37 +301,6 @@ export function createProgram(cwd: string): Command {
|
|||||||
taskClaim(projectCwd, id, options.agent);
|
taskClaim(projectCwd, id, options.agent);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
taskCmd
|
|
||||||
.command('dispatch <id>')
|
|
||||||
.description('Architect: explicitly start a session-less agent for an open task')
|
|
||||||
.requiredOption('--agent <agent>', 'Architect-dispatched agent')
|
|
||||||
.action(async (id, options) => {
|
|
||||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
||||||
if (serverUrl) {
|
|
||||||
await runRemote(serverUrl, async () => {
|
|
||||||
await remoteClient.dispatchTask(serverUrl, id, options.agent);
|
|
||||||
console.log(`AgentHub: Architect started ${options.agent} for ${id}`);
|
|
||||||
});
|
|
||||||
} else taskDispatch(projectCwd, id, options.agent);
|
|
||||||
});
|
|
||||||
taskCmd
|
|
||||||
.command('record')
|
|
||||||
.description('Record work already completed outside AgentHub')
|
|
||||||
.requiredOption('--title <title>', 'Completed work')
|
|
||||||
.requiredOption('--by <agent>', 'Who completed it')
|
|
||||||
.option('--description <description>', 'Details')
|
|
||||||
.option('--role <role>', 'Role')
|
|
||||||
.option('--priority <priority>', 'Priority')
|
|
||||||
.action(async (options) => {
|
|
||||||
const payload = { title: options.title, doneBy: options.by, description: options.description, role: options.role, priority: options.priority };
|
|
||||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
||||||
if (serverUrl) {
|
|
||||||
await runRemote(serverUrl, async () => {
|
|
||||||
const task = await remoteClient.recordExternalTask(serverUrl, payload);
|
|
||||||
console.log(`AgentHub: External work recorded ${task.id} by ${task.doneBy}`);
|
|
||||||
});
|
|
||||||
} else taskRecord(projectCwd, payload);
|
|
||||||
});
|
|
||||||
taskCmd
|
taskCmd
|
||||||
.command('done <id>')
|
.command('done <id>')
|
||||||
.description('Mark a task as done')
|
.description('Mark a task as done')
|
||||||
@ -797,7 +699,7 @@ export function createProgram(cwd: string): Command {
|
|||||||
const agentCmd = new Command('agent').description('Per-agent machine setup');
|
const agentCmd = new Command('agent').description('Per-agent machine setup');
|
||||||
agentCmd
|
agentCmd
|
||||||
.command('setup')
|
.command('setup')
|
||||||
.description('Auto-start: install lifecycle hooks; Codex gets a native Stop-hook work-loop wake adapter')
|
.description('Auto-start: write a SessionStart hook (Claude Code / Codex / Kimi) so sessions enter the work loop')
|
||||||
.requiredOption('--agent <name>', 'This machine\'s agent name')
|
.requiredOption('--agent <name>', 'This machine\'s agent name')
|
||||||
.requiredOption('--role <role>', 'This agent\'s role (implementer / architect / tester)')
|
.requiredOption('--role <role>', 'This agent\'s role (implementer / architect / tester)')
|
||||||
.option('--cli <cli>', 'CLI to target: claude | codex | kimi (default: inferred from the agent name)')
|
.option('--cli <cli>', 'CLI to target: claude | codex | kimi (default: inferred from the agent name)')
|
||||||
@ -807,44 +709,6 @@ export function createProgram(cwd: string): Command {
|
|||||||
});
|
});
|
||||||
program.addCommand(agentCmd);
|
program.addCommand(agentCmd);
|
||||||
|
|
||||||
const roleCmd = new Command('role').description('Inspect or change the agent assigned to a team role');
|
|
||||||
roleCmd
|
|
||||||
.command('show [role]')
|
|
||||||
.description('Show preferred agents for one role or all roles')
|
|
||||||
.action(async (role?: string) => {
|
|
||||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
||||||
if (serverUrl) {
|
|
||||||
const health = await remoteClient.getHealth(serverUrl);
|
|
||||||
const roles = await remoteClient.listRoles(serverUrl);
|
|
||||||
const entries = Object.entries(roles).filter(([name]) => !role || name === role);
|
|
||||||
if (!entries.length) throw new Error(`Unknown role: ${role}`);
|
|
||||||
for (const [name, spec] of entries) {
|
|
||||||
const live = health.agents.find((a) => a.name === spec.preferredAgent);
|
|
||||||
console.log(`${name}: ${spec.preferredAgent}${live ? ` (${live.state})` : ''}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const config = loadConfig(projectCwd);
|
|
||||||
const entries = Object.entries(config.roles).filter(([name]) => !role || name === role);
|
|
||||||
if (!entries.length) throw new Error(`Unknown role: ${role}`);
|
|
||||||
for (const [name, spec] of entries) console.log(`${name}: ${spec.preferredAgent}`);
|
|
||||||
});
|
|
||||||
roleCmd
|
|
||||||
.command('set <role>')
|
|
||||||
.description('Atomically switch a role to another configured agent')
|
|
||||||
.requiredOption('--agent <name>', 'Agent that should hold the role')
|
|
||||||
.action(async (role: string, options: { agent: string }) => {
|
|
||||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
||||||
const result = serverUrl
|
|
||||||
? await remoteClient.setPreferredAgent(serverUrl, role, options.agent)
|
|
||||||
: setPreferredAgent(projectCwd, role, options.agent);
|
|
||||||
console.log(`AgentHub: ${result.role} switched ${result.previousAgent} → ${result.agent}`);
|
|
||||||
if (result.role === 'architect') {
|
|
||||||
console.log(`Start the new architect with: agenthub start ${result.agent}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
program.addCommand(roleCmd);
|
|
||||||
|
|
||||||
// ─── org sync (CLAUDE.md → team structure) ───────────────────────────────
|
// ─── org sync (CLAUDE.md → team structure) ───────────────────────────────
|
||||||
const orgCmd = new Command('org').description('Team org-chart structure');
|
const orgCmd = new Command('org').description('Team org-chart structure');
|
||||||
orgCmd
|
orgCmd
|
||||||
@ -867,47 +731,9 @@ export function createProgram(cwd: string): Command {
|
|||||||
.command('hook-context')
|
.command('hook-context')
|
||||||
.description('Print the auto-start work-loop instruction (used by the SessionStart hook)')
|
.description('Print the auto-start work-loop instruction (used by the SessionStart hook)')
|
||||||
.requiredOption('--agent <name>', 'Agent name')
|
.requiredOption('--agent <name>', 'Agent name')
|
||||||
.option('--role <role>', 'Agent role override (normally resolved dynamically)')
|
.requiredOption('--role <role>', 'Agent role')
|
||||||
.action(async (options: { agent: string; role?: string }) => {
|
.action((options: { agent: string; role: string }) => {
|
||||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
hookContext({ agent: options.agent, role: options.role });
|
||||||
let role = options.role;
|
|
||||||
if (!role && serverUrl) {
|
|
||||||
const health = await remoteClient.getHealth(serverUrl);
|
|
||||||
role = health.agents.find((a) => a.name.toLowerCase() === options.agent.toLowerCase())?.role;
|
|
||||||
}
|
|
||||||
if (!role) {
|
|
||||||
const config = loadConfig(projectCwd);
|
|
||||||
role = config.roles.architect?.preferredAgent === options.agent
|
|
||||||
? 'architect'
|
|
||||||
: config.agents?.[options.agent]?.role ?? 'implementer';
|
|
||||||
}
|
|
||||||
hookContext({ agent: options.agent, role });
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('hook-stop')
|
|
||||||
.description('Wait for AgentHub work and resume the current Codex thread (used by the Stop hook)')
|
|
||||||
.requiredOption('--agent <name>', 'Agent name')
|
|
||||||
.option('--role <role>', 'Agent role override (normally resolved dynamically)')
|
|
||||||
.action(async (options: { agent: string; role?: string }) => {
|
|
||||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
||||||
let role = options.role;
|
|
||||||
if (!role && serverUrl) {
|
|
||||||
const health = await remoteClient.getHealth(serverUrl);
|
|
||||||
role = health.agents.find((a) => a.name.toLowerCase() === options.agent.toLowerCase())?.role;
|
|
||||||
}
|
|
||||||
if (!role) {
|
|
||||||
const config = loadConfig(projectCwd);
|
|
||||||
role = config.roles.architect?.preferredAgent === options.agent
|
|
||||||
? 'architect'
|
|
||||||
: config.agents?.[options.agent]?.role ?? 'implementer';
|
|
||||||
}
|
|
||||||
await codexStopHook({
|
|
||||||
serverUrl,
|
|
||||||
projectCwd,
|
|
||||||
agent: options.agent,
|
|
||||||
role,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
@ -945,13 +771,12 @@ export function createProgram(cwd: string): Command {
|
|||||||
.command('mcp [action]')
|
.command('mcp [action]')
|
||||||
.description('Start the MCP server (stdio). `agenthub mcp install` writes .mcp.json so MCP-aware agents auto-register.')
|
.description('Start the MCP server (stdio). `agenthub mcp install` writes .mcp.json so MCP-aware agents auto-register.')
|
||||||
.option('--print', 'Dry run: print the config instead of writing .mcp.json')
|
.option('--print', 'Dry run: print the config instead of writing .mcp.json')
|
||||||
.option('--agent <name>', 'Bind the MCP push channel to this agent at startup (recommended: the bridge then pushes from the first second, without waiting for a tool call)')
|
.action(async (action: string | undefined, options: { print?: boolean }) => {
|
||||||
.action(async (action: string | undefined, options: { print?: boolean; agent?: string }) => {
|
|
||||||
if (action === 'install') {
|
if (action === 'install') {
|
||||||
installMcp(cwd, { print: options.print, agent: options.agent });
|
installMcp(cwd, { print: options.print });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await startMcpServer(cwd, { agent: options.agent });
|
await startMcpServer(cwd);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── hello (presence) ──────────────────────────────────────────────────────
|
// ─── hello (presence) ──────────────────────────────────────────────────────
|
||||||
@ -975,35 +800,17 @@ export function createProgram(cwd: string): Command {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── start (onboarding) ────────────────────────────────────────────────────
|
// ─── start (onboarding) ────────────────────────────────────────────────────
|
||||||
// Session-Einstieg für einen Agenten. `agenthub start codex` (positional) ist
|
|
||||||
// die Form, die ein Mensch in eine frische Session tippt — sie druckt zuerst
|
|
||||||
// das verbindliche Briefing (Loop, Check-in-Kanal, DEC-0035) und macht dann
|
|
||||||
// das Onboarding (anmelden, adressierten Task claimen, Handoff zeigen).
|
|
||||||
// `--agent` bleibt für Skripte/Hooks erhalten.
|
|
||||||
program
|
program
|
||||||
.command('start [agent]')
|
.command('start')
|
||||||
.description('Start an agent session: briefing + announce + claim the task addressed to you')
|
.description('Onboard: announce, claim the task addressed to you, print it + its handoff')
|
||||||
.option('--agent <name>', 'Agent name (alternative to the positional argument)')
|
.requiredOption('--agent <name>', 'Agent name')
|
||||||
.option('--role <role>', 'Role (default: from the roster, else implementer)')
|
.option('--role <role>', 'Role (default: implementer)', 'implementer')
|
||||||
.option('--no-briefing', 'Skip the standing briefing (onboarding output only)')
|
.action(async (options) => {
|
||||||
.action(async (positional: string | undefined, options: { agent?: string; role?: string; briefing?: boolean }) => {
|
|
||||||
const agent = positional ?? options.agent;
|
|
||||||
if (!agent) {
|
|
||||||
console.error('Which agent? Usage: agenthub start <agent> (e.g. agenthub start codex)');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
let role = options.role ?? 'implementer';
|
|
||||||
if (options.briefing !== false) {
|
|
||||||
role = await startAgentSession({ serverUrl, projectCwd, agent, role: options.role });
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
// Der Architekt claimt nichts — sein Start ist der Lagebericht.
|
|
||||||
if (role === 'architect') return;
|
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, () => startAgent({ serverUrl, projectCwd, agent, role }));
|
await runRemote(serverUrl, () => startAgent({ serverUrl, projectCwd, agent: options.agent, role: options.role }));
|
||||||
} else {
|
} else {
|
||||||
await startAgent({ projectCwd, agent, role });
|
await startAgent({ projectCwd, agent: options.agent, role: options.role });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1035,7 +842,6 @@ export function createProgram(cwd: string): Command {
|
|||||||
.option('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier')
|
.option('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier')
|
||||||
.option('--await-message <agent>', 'Exit when an unread message arrives for agent/role; architect message notifier')
|
.option('--await-message <agent>', 'Exit when an unread message arrives for agent/role; architect message notifier')
|
||||||
.option('--new-only', 'With --await-review/--await-message: ignore existing backlog on connect (re-armable without spinning)')
|
.option('--new-only', 'With --await-review/--await-message: ignore existing backlog on connect (re-armable without spinning)')
|
||||||
.option('--ignore-from <agents>', 'With --await-message: comma-separated senders to ignore (e.g. "agenthub" to mute watchdog reminders)')
|
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
const { serverUrl } = await resolveContext(program, cwd);
|
const { serverUrl } = await resolveContext(program, cwd);
|
||||||
if (!serverUrl) {
|
if (!serverUrl) {
|
||||||
@ -1048,7 +854,6 @@ export function createProgram(cwd: string): Command {
|
|||||||
role: options.role as string | undefined,
|
role: options.role as string | undefined,
|
||||||
awaitReview: options.awaitReview as boolean | undefined,
|
awaitReview: options.awaitReview as boolean | undefined,
|
||||||
awaitMessage: options.awaitMessage as string | undefined,
|
awaitMessage: options.awaitMessage as string | undefined,
|
||||||
ignoreFrom: typeof options.ignoreFrom === 'string' ? options.ignoreFrom.split(',') : undefined,
|
|
||||||
newOnly: options.newOnly as boolean | undefined,
|
newOnly: options.newOnly as boolean | undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import type { Task, Handoff, Decision, Memory, Message, Ask, ActivityItem } from
|
|||||||
import type { IndexEntry } from '../core/index.js';
|
import type { IndexEntry } from '../core/index.js';
|
||||||
import type { InboxMessage } from '../core/services/messageService.js';
|
import type { InboxMessage } from '../core/services/messageService.js';
|
||||||
import type { TaskLogEntry } from '../core/services/taskLogService.js';
|
import type { TaskLogEntry } from '../core/services/taskLogService.js';
|
||||||
import type { HealthReport } from '../core/services/presenceService.js';
|
|
||||||
|
|
||||||
export class RemoteError extends Error {
|
export class RemoteError extends Error {
|
||||||
constructor(public status: number, message: string) {
|
constructor(public status: number, message: string) {
|
||||||
@ -39,61 +38,10 @@ export const remoteClient = {
|
|||||||
return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body);
|
return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getHealth(baseUrl: string): Promise<HealthReport> {
|
|
||||||
return request<HealthReport>(baseUrl, 'GET', '/health');
|
|
||||||
},
|
|
||||||
|
|
||||||
async setPreferredAgent(
|
|
||||||
baseUrl: string,
|
|
||||||
role: string,
|
|
||||||
agent: string,
|
|
||||||
): Promise<{ role: string; agent: string; previousAgent: string }> {
|
|
||||||
return request(baseUrl, 'PATCH', `/roles/${encodeURIComponent(role)}`, { agent });
|
|
||||||
},
|
|
||||||
|
|
||||||
async listRoles(baseUrl: string): Promise<Record<string, { preferredAgent: string; description?: string }>> {
|
|
||||||
return request(baseUrl, 'GET', '/roles');
|
|
||||||
},
|
|
||||||
|
|
||||||
async getArchitectPulse(
|
|
||||||
baseUrl: string,
|
|
||||||
sinceSeq?: number,
|
|
||||||
): Promise<{
|
|
||||||
nextSeq: number;
|
|
||||||
events: Array<{
|
|
||||||
seq: number;
|
|
||||||
type: string;
|
|
||||||
action: string;
|
|
||||||
id: string;
|
|
||||||
status?: string;
|
|
||||||
assignedTo?: string;
|
|
||||||
}>;
|
|
||||||
}> {
|
|
||||||
const query = sinceSeq === undefined ? '' : `?sinceSeq=${sinceSeq}`;
|
|
||||||
return request(baseUrl, 'GET', `/architect/pulse${query}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async getAgentIdentity(baseUrl: string, agent: string): Promise<{ canonical: string; names: string[]; workTimeoutSec?: number }> {
|
|
||||||
return request<{ canonical: string; names: string[]; workTimeoutSec?: number }>(
|
|
||||||
baseUrl,
|
|
||||||
'GET',
|
|
||||||
`/agents/${encodeURIComponent(agent)}/identity`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async announce(baseUrl: string, agent: string, role?: string, action: 'joined' | 'left' = 'joined'): Promise<void> {
|
async announce(baseUrl: string, agent: string, role?: string, action: 'joined' | 'left' = 'joined'): Promise<void> {
|
||||||
await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action });
|
await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action });
|
||||||
},
|
},
|
||||||
|
|
||||||
async setLoop(baseUrl: string, agent: string, active: boolean, reason?: string): Promise<void> {
|
|
||||||
await request<{ ok: boolean }>(
|
|
||||||
baseUrl,
|
|
||||||
'POST',
|
|
||||||
`/agents/${encodeURIComponent(agent)}/loop`,
|
|
||||||
{ active, reason },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateStatus(baseUrl: string): Promise<string> {
|
async updateStatus(baseUrl: string): Promise<string> {
|
||||||
return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body);
|
return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body);
|
||||||
},
|
},
|
||||||
@ -101,9 +49,6 @@ export const remoteClient = {
|
|||||||
async createTask(baseUrl: string, options: Partial<Task>): Promise<Task> {
|
async createTask(baseUrl: string, options: Partial<Task>): Promise<Task> {
|
||||||
return request<Task>(baseUrl, 'POST', '/tasks', options);
|
return request<Task>(baseUrl, 'POST', '/tasks', options);
|
||||||
},
|
},
|
||||||
async recordExternalTask(baseUrl: string, options: Partial<Task> & { title: string; doneBy: string }): Promise<Task> {
|
|
||||||
return request<Task>(baseUrl, 'POST', '/tasks/record', options);
|
|
||||||
},
|
|
||||||
|
|
||||||
async listTasks(baseUrl: string, filters?: { status?: string; role?: string }): Promise<IndexEntry[]> {
|
async listTasks(baseUrl: string, filters?: { status?: string; role?: string }): Promise<IndexEntry[]> {
|
||||||
const params = new URLSearchParams((filters ?? {}) as Record<string, string>);
|
const params = new URLSearchParams((filters ?? {}) as Record<string, string>);
|
||||||
@ -118,9 +63,6 @@ export const remoteClient = {
|
|||||||
async claimTask(baseUrl: string, id: string, agentName: string): Promise<Task> {
|
async claimTask(baseUrl: string, id: string, agentName: string): Promise<Task> {
|
||||||
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName });
|
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName });
|
||||||
},
|
},
|
||||||
async dispatchTask(baseUrl: string, id: string, agent: string): Promise<Task> {
|
|
||||||
return request<Task>(baseUrl, 'POST', `/tasks/${id}/dispatch`, { agent });
|
|
||||||
},
|
|
||||||
|
|
||||||
async doneTask(
|
async doneTask(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
@ -159,12 +101,6 @@ export const remoteClient = {
|
|||||||
return request<TaskLogEntry>(baseUrl, 'POST', `/tasks/${id}/log`, entry);
|
return request<TaskLogEntry>(baseUrl, 'POST', `/tasks/${id}/log`, entry);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** TSK-0274: was wartet auf diesen Agenten (Check-in-Kanal für Arbeitende). */
|
|
||||||
async getPending(baseUrl: string, agent: string, taskId?: string): Promise<unknown> {
|
|
||||||
const query = taskId ? `?taskId=${encodeURIComponent(taskId)}` : '';
|
|
||||||
return request<unknown>(baseUrl, 'GET', `/agents/${encodeURIComponent(agent)}/pending${query}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
|
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
|
||||||
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
|
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
import { getConfigPath } from './paths.js';
|
import { getConfigPath } from './paths.js';
|
||||||
import { ConfigSchema, WatchdogConfigSchema, type Config } from './schema.js';
|
import { ConfigSchema, type Config } from './schema.js';
|
||||||
|
|
||||||
export function defaultConfig(projectName: string): Config {
|
export function defaultConfig(projectName: string): Config {
|
||||||
return {
|
return {
|
||||||
@ -15,7 +15,6 @@ export function defaultConfig(projectName: string): Config {
|
|||||||
tester: { preferredAgent: 'codex' },
|
tester: { preferredAgent: 'codex' },
|
||||||
},
|
},
|
||||||
serverUrl: undefined,
|
serverUrl: undefined,
|
||||||
watchdog: WatchdogConfigSchema.parse({}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ export const MemoryCategory = z.enum(['architecture', 'product', 'technical', 'i
|
|||||||
export const Priority = z.enum(['low', 'medium', 'high', 'critical']);
|
export const Priority = z.enum(['low', 'medium', 'high', 'critical']);
|
||||||
export const Role = z.enum(['architect', 'implementer', 'reviewer', 'tester']);
|
export const Role = z.enum(['architect', 'implementer', 'reviewer', 'tester']);
|
||||||
export const DelegationMode = z.enum(['manual', 'suggest', 'auto']);
|
export const DelegationMode = z.enum(['manual', 'suggest', 'auto']);
|
||||||
export const AgentDispatch = z.enum(['loop', 'architect']);
|
|
||||||
|
|
||||||
export const TaskSchema = z.object({
|
export const TaskSchema = z.object({
|
||||||
id: z.string().regex(/^TSK-\d{4}$/),
|
id: z.string().regex(/^TSK-\d{4}$/),
|
||||||
@ -28,9 +27,6 @@ export const TaskSchema = z.object({
|
|||||||
doneBy: z.string().optional(),
|
doneBy: z.string().optional(),
|
||||||
doneTokens: z.number().int().nonnegative().optional(),
|
doneTokens: z.number().int().nonnegative().optional(),
|
||||||
doneDuration: z.number().int().nonnegative().optional(),
|
doneDuration: z.number().int().nonnegative().optional(),
|
||||||
/** Honest provenance for work recorded after it happened outside AgentHub. */
|
|
||||||
origin: z.enum(['agenthub', 'external']).default('agenthub'),
|
|
||||||
recordedAt: z.string().datetime().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const HandoffSchema = z.object({
|
export const HandoffSchema = z.object({
|
||||||
@ -158,21 +154,6 @@ export const RoleConfigSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const AgentConfigSchema = z.object({
|
export const AgentConfigSchema = z.object({
|
||||||
role: Role,
|
role: Role,
|
||||||
/** loop = self-claiming session; architect = spawned/claimed explicitly by the architect. */
|
|
||||||
dispatch: AgentDispatch.default('loop'),
|
|
||||||
/** Previous/alternate names that resolve to this canonical roster key. */
|
|
||||||
aliases: z.array(z.string().min(1)).default([]),
|
|
||||||
/**
|
|
||||||
* Wie lange `agenthub_work` fuer DIESEN Agenten blockieren darf.
|
|
||||||
*
|
|
||||||
* Der Wert haengt am MCP-CLIENT, nicht am Hub: Kimi-Code bricht Requests nach
|
|
||||||
* gut einer Minute ab (-32001), Codex vertraegt mehrere Minuten. Ein globaler
|
|
||||||
* Default zwingt beide auf das Minimum des schwaechsten Clients — der
|
|
||||||
* robustere Agent kehrt dann unnoetig oft leer zurueck, und JEDE Rueckkehr
|
|
||||||
* ist eine Gelegenheit, aus dem Loop zu fallen. Genau daran unterschied sich
|
|
||||||
* codex' Zuverlaessigkeit von kimis.
|
|
||||||
*/
|
|
||||||
workTimeoutSec: z.number().int().positive().max(600).optional(),
|
|
||||||
/** Display model, e.g. "Opus 4.8", "Sonnet 4.6", "Kimi K2", "GPT-5 Codex". */
|
/** Display model, e.g. "Opus 4.8", "Sonnet 4.6", "Kimi K2", "GPT-5 Codex". */
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
/** Provider/company for the logo: "anthropic" | "openai" | "moonshot" | … */
|
/** Provider/company for the logo: "anthropic" | "openai" | "moonshot" | … */
|
||||||
@ -203,30 +184,9 @@ export const OrgNodeSchema = z.object({
|
|||||||
parentId: z.string().optional(),
|
parentId: z.string().optional(),
|
||||||
/** Provider override for the icon when there is no linked agent. */
|
/** Provider override for the icon when there is no linked agent. */
|
||||||
kind: z.string().optional(),
|
kind: z.string().optional(),
|
||||||
/** When linked to an agent, org sync copies this into the roster. */
|
|
||||||
dispatch: AgentDispatch.optional(),
|
|
||||||
});
|
});
|
||||||
export type OrgNode = z.infer<typeof OrgNodeSchema>;
|
export type OrgNode = z.infer<typeof OrgNodeSchema>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-side watchdog tuning (TSK-0226). All values optional — the defaults
|
|
||||||
* below apply when the section (or a single key) is absent from the config.
|
|
||||||
*/
|
|
||||||
export const WatchdogConfigSchema = z.object({
|
|
||||||
/** Master switch; false disables the watchdog loop entirely. */
|
|
||||||
enabled: z.boolean().default(true),
|
|
||||||
/** How often the watchdog scans tasks. */
|
|
||||||
intervalMs: z.number().int().positive().default(60_000),
|
|
||||||
/** OPEN + assigned but unclaimed for this long (high/critical) → re-notify. */
|
|
||||||
unclaimedHighMs: z.number().int().positive().default(3 * 60_000),
|
|
||||||
/** OPEN + assigned but unclaimed for this long (medium/low) → re-notify. */
|
|
||||||
unclaimedDefaultMs: z.number().int().positive().default(10 * 60_000),
|
|
||||||
/** IN_PROGRESS with no task-log line for this long → alert the architect. */
|
|
||||||
staleInProgressMs: z.number().int().positive().default(15 * 60_000),
|
|
||||||
/** REVIEW untouched for this long → remind the architect. */
|
|
||||||
staleReviewMs: z.number().int().positive().default(5 * 60_000),
|
|
||||||
}).default({});
|
|
||||||
|
|
||||||
export const ConfigSchema = z.object({
|
export const ConfigSchema = z.object({
|
||||||
version: z.literal('1'),
|
version: z.literal('1'),
|
||||||
projectName: z.string().min(1),
|
projectName: z.string().min(1),
|
||||||
@ -237,8 +197,6 @@ export const ConfigSchema = z.object({
|
|||||||
/** Team org chart (arbitrary depth). When present, /team renders this tree. */
|
/** Team org chart (arbitrary depth). When present, /team renders this tree. */
|
||||||
org: z.array(OrgNodeSchema).optional(),
|
org: z.array(OrgNodeSchema).optional(),
|
||||||
serverUrl: z.string().url().optional(),
|
serverUrl: z.string().url().optional(),
|
||||||
/** Watchdog thresholds; absent config ⇒ sane defaults. */
|
|
||||||
watchdog: WatchdogConfigSchema,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Task = z.infer<typeof TaskSchema>;
|
export type Task = z.infer<typeof TaskSchema>;
|
||||||
@ -249,4 +207,3 @@ export type Message = z.infer<typeof MessageSchema>;
|
|||||||
export type Ask = z.infer<typeof AskSchema>;
|
export type Ask = z.infer<typeof AskSchema>;
|
||||||
export type Status = z.infer<typeof StatusSchema>;
|
export type Status = z.infer<typeof StatusSchema>;
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
export type WatchdogConfig = z.infer<typeof WatchdogConfigSchema>;
|
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
import { getTask, listTasks } from './taskService.js';
|
import { getTask, listTasks } from './taskService.js';
|
||||||
import { listHandoffs } from './handoffService.js';
|
|
||||||
import { listMessages } from './messageService.js';
|
|
||||||
import { loadConfig } from '../config.js';
|
|
||||||
import { getRoster, inferKind, type RosterEntry } from './rosterService.js';
|
import { getRoster, inferKind, type RosterEntry } from './rosterService.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,14 +39,6 @@ export const MAX_ACTIVE_MIN_PER_TASK = 45;
|
|||||||
*/
|
*/
|
||||||
export const MAX_LIVE_ESTIMATE_MIN_PER_TASK = 12;
|
export const MAX_LIVE_ESTIMATE_MIN_PER_TASK = 12;
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimated tokens per architect/coordination action (review, handoff,
|
|
||||||
* message, done/reopen decision). The architect rarely OWNS tasks, so without
|
|
||||||
* this their work was invisible in the report (TSK-0226). ≈ 30s of active
|
|
||||||
* work per action — deliberately rough and flagged estimated like the rest.
|
|
||||||
*/
|
|
||||||
export const TOKENS_PER_COORDINATION_ACTION = 1500;
|
|
||||||
|
|
||||||
/** USD → EUR display rate (rough, labelled approximate in the UI). */
|
/** USD → EUR display rate (rough, labelled approximate in the UI). */
|
||||||
const USD_TO_EUR = 0.92;
|
const USD_TO_EUR = 0.92;
|
||||||
|
|
||||||
@ -132,9 +121,6 @@ export interface AgentBudget {
|
|||||||
/** costEur / budgetEur, clamped 0..1 (only when a budget is set). */
|
/** costEur / budgetEur, clamped 0..1 (only when a budget is set). */
|
||||||
budgetUsed?: number;
|
budgetUsed?: number;
|
||||||
taskCount: number;
|
taskCount: number;
|
||||||
/** Coordination actions attributed to this agent (reviews, handoffs,
|
|
||||||
* messages, approvals) — the counter behind the activity estimate. */
|
|
||||||
actions: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BudgetReport {
|
export interface BudgetReport {
|
||||||
@ -167,7 +153,6 @@ export function computeBudget(cwd: string): BudgetReport {
|
|||||||
costEur: 0,
|
costEur: 0,
|
||||||
budgetEur: r?.budgetEur,
|
budgetEur: r?.budgetEur,
|
||||||
taskCount: 0,
|
taskCount: 0,
|
||||||
actions: 0,
|
|
||||||
};
|
};
|
||||||
acc.set(name, a);
|
acc.set(name, a);
|
||||||
}
|
}
|
||||||
@ -176,8 +161,7 @@ export function computeBudget(cwd: string): BudgetReport {
|
|||||||
// Seed rostered agents so they show even at zero.
|
// Seed rostered agents so they show even at zero.
|
||||||
for (const r of roster) ensure(r.name);
|
for (const r of roster) ensure(r.name);
|
||||||
|
|
||||||
const tasks = listTasks(cwd);
|
for (const t of listTasks(cwd)) {
|
||||||
for (const t of tasks) {
|
|
||||||
const owner = t.assignedTo;
|
const owner = t.assignedTo;
|
||||||
if (!owner) continue;
|
if (!owner) continue;
|
||||||
const { tokens, estimated } = tokensForTask(
|
const { tokens, estimated } = tokensForTask(
|
||||||
@ -199,50 +183,6 @@ export function computeBudget(cwd: string): BudgetReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Architect / coordination activity (TSK-0226) ─────────────────────────
|
|
||||||
// Reviews, handoffs, messages and done/reopen decisions rarely leave the
|
|
||||||
// architect as a task OWNER — count them as actions and estimate activity
|
|
||||||
// tokens so the architect's work shows up like implementer work.
|
|
||||||
const actionCount = new Map<string, number>();
|
|
||||||
const bump = (name: string | undefined, n = 1) => {
|
|
||||||
if (!name) return;
|
|
||||||
actionCount.set(name, (actionCount.get(name) ?? 0) + n);
|
|
||||||
};
|
|
||||||
const preferredByRole = new Map<string, string>();
|
|
||||||
try {
|
|
||||||
for (const [role, cfg] of Object.entries(loadConfig(cwd).roles)) preferredByRole.set(role, cfg.preferredAgent);
|
|
||||||
} catch {
|
|
||||||
/* no config → role fallback stays empty */
|
|
||||||
}
|
|
||||||
for (const t of tasks) {
|
|
||||||
// A pending review is architect work in flight (reviewer ≠ assignee by construction).
|
|
||||||
if (t.reviewer) bump(t.reviewer);
|
|
||||||
// Approval: done by someone other than the task owner (e.g. architect approves).
|
|
||||||
// doneBy isn't indexed — read the entity file for done tasks only.
|
|
||||||
if (t.status === 'done') {
|
|
||||||
try {
|
|
||||||
const { task } = getTask(cwd, t.id);
|
|
||||||
if (task.doneBy && task.doneBy !== t.assignedTo) bump(task.doneBy);
|
|
||||||
} catch {
|
|
||||||
/* unreadable entity → skip */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const h of listHandoffs(cwd)) {
|
|
||||||
bump(h.fromAgent ?? (h.fromRole ? preferredByRole.get(h.fromRole) : undefined));
|
|
||||||
}
|
|
||||||
for (const m of listMessages(cwd)) {
|
|
||||||
bump(m.from);
|
|
||||||
}
|
|
||||||
for (const [name, count] of actionCount) {
|
|
||||||
const a = ensure(name);
|
|
||||||
const tokens = count * TOKENS_PER_COORDINATION_ACTION;
|
|
||||||
a.actions += count;
|
|
||||||
a.tokens += tokens;
|
|
||||||
a.estimatedTokens += tokens;
|
|
||||||
a.estimated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const agents = Array.from(acc.values());
|
const agents = Array.from(acc.values());
|
||||||
for (const a of agents) {
|
for (const a of agents) {
|
||||||
a.costEur = costEur(a.tokens, a.model);
|
a.costEur = costEur(a.tokens, a.model);
|
||||||
@ -261,7 +201,7 @@ export function computeBudget(cwd: string): BudgetReport {
|
|||||||
totals: { tokens: totalTokens, costEur: totalCost, estimated: anyEstimated },
|
totals: { tokens: totalTokens, costEur: totalCost, estimated: anyEstimated },
|
||||||
assumptions: {
|
assumptions: {
|
||||||
tokensPerActiveMin: TOKENS_PER_ACTIVE_MIN,
|
tokensPerActiveMin: TOKENS_PER_ACTIVE_MIN,
|
||||||
note: `External CLIs do not report tokens; figures marked ~ are estimated from active time-on-task (live states capped at ${MAX_LIVE_ESTIMATE_MIN_PER_TASK} min/task, done fallbacks at ${MAX_ACTIVE_MIN_PER_TASK} min/task) plus coordination actions (reviews/handoffs/messages/approvals ≈ ${TOKENS_PER_COORDINATION_ACTION} tok each, see 'actions'). Cost is a directional estimate at blended model rates, not billing.`,
|
note: `External CLIs do not report tokens; figures marked ~ are estimated from active time-on-task (live states capped at ${MAX_LIVE_ESTIMATE_MIN_PER_TASK} min/task, done fallbacks at ${MAX_ACTIVE_MIN_PER_TASK} min/task). Cost is a directional estimate at blended model rates, not billing.`,
|
||||||
},
|
},
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,216 +0,0 @@
|
|||||||
import { listTasks } from './taskService.js';
|
|
||||||
import { listMessages } from './messageService.js';
|
|
||||||
import { listAsks } from './askService.js';
|
|
||||||
import { listHandoffs } from './handoffService.js';
|
|
||||||
import { agentIdentity } from './identityService.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check-in-Kanal (TSK-0274) — der Rückkanal für ARBEITENDE Agenten.
|
|
||||||
*
|
|
||||||
* Hintergrund (die Wurzel, die uns sessionlang im Feuerwehrmodus hielt):
|
|
||||||
* Ein Agent empfängt SSE-Events ausschließlich, solange er in `agenthub_work`
|
|
||||||
* blockiert. Führt er einen Task aus, ist er vollständig taub — kein Reopen,
|
|
||||||
* kein Cancel, keine Nachricht erreicht ihn, bis er von sich aus fertig wird.
|
|
||||||
* Ein echter Interrupt in einen laufenden Agenten-Turn existiert nicht.
|
|
||||||
*
|
|
||||||
* Deshalb nutzen wir den einzigen realen Kanal: die Momente, in denen der
|
|
||||||
* Agent von SICH AUS mit dem Hub spricht (allen voran `agenthub_task_log`,
|
|
||||||
* das wir nach jedem Teilschritt ohnehin verlangen). Jede solche Antwort
|
|
||||||
* trägt ab jetzt mit, was auf ihn wartet.
|
|
||||||
*
|
|
||||||
* Bewusst ZUSTANDSLOS aus Tasks + Messages abgeleitet — kein Event-Log, kein
|
|
||||||
* In-Memory-Puffer, nichts, was ein Hub-Neustart verlieren kann. Ein Reopen
|
|
||||||
* ist erkennbar, weil `reopenTask` den Claim abräumt und `assignedTo` stehen
|
|
||||||
* lässt: die Task taucht dadurch wieder als "offen und an mich adressiert" auf.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Wie viele Nachrichten-Vorschauen maximal mitfließen (Antwort bleibt klein). */
|
|
||||||
const MAX_MESSAGE_PREVIEWS = 3;
|
|
||||||
/** Auf diese Länge wird ein Nachrichten-Vorschautext gekürzt. */
|
|
||||||
const PREVIEW_CHARS = 120;
|
|
||||||
|
|
||||||
export interface PendingMessagePreview {
|
|
||||||
id: string;
|
|
||||||
from: string;
|
|
||||||
preview: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PendingForAgent {
|
|
||||||
/**
|
|
||||||
* Gesetzt, wenn der Agent an einem Task arbeitet, der ihm nicht mehr gehört
|
|
||||||
* (reopened, cancelled, weggenommen). DAS ist der Fall, für den es den
|
|
||||||
* Kanal gibt: der Agent muss sofort aufhören, nicht erst am Ende erfahren.
|
|
||||||
*/
|
|
||||||
interrupted?: {
|
|
||||||
taskId: string;
|
|
||||||
status: string;
|
|
||||||
reason: string;
|
|
||||||
action: string;
|
|
||||||
};
|
|
||||||
/** Offene Tasks, die an den Agenten adressiert sind (inkl. Reopens). */
|
|
||||||
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. */
|
|
||||||
awaitingReview: string[];
|
|
||||||
/**
|
|
||||||
* Vom Agenten gestellte Fragen, die inzwischen beantwortet sind.
|
|
||||||
*
|
|
||||||
* Warum das hierher gehört: `agenthub_ask(wait:true)` wartet nur 300 s. Der
|
|
||||||
* Architekt braucht für eine Architekturentscheidung regelmäßig länger (real:
|
|
||||||
* 18 min bei ASK-0004). Danach ist der Agent dormant und die Antwort erreicht
|
|
||||||
* ihn nie — ein Mensch muss ihn wecken. Über den Check-in kommt sie beim
|
|
||||||
* nächsten Hub-Kontakt an.
|
|
||||||
*/
|
|
||||||
answeredAsks: Array<{ id: string; answer: string }>;
|
|
||||||
unreadCount: number;
|
|
||||||
messages: PendingMessagePreview[];
|
|
||||||
/** Klartext für das Modell — nur gesetzt, wenn wirklich etwas anliegt. */
|
|
||||||
note?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function previewOf(text: unknown): string {
|
|
||||||
const raw = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
||||||
return raw.length > PREVIEW_CHARS ? `${raw.slice(0, PREVIEW_CHARS)}…` : raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Was wartet gerade auf diesen Agenten?
|
|
||||||
*
|
|
||||||
* @param currentTaskId Der Task, an dem der Agent NACH EIGENER AUSSAGE arbeitet
|
|
||||||
* (z. B. die ID aus `agenthub_task_log`). Nur damit lässt sich erkennen, dass
|
|
||||||
* ihm die Arbeit unter den Händen weggezogen wurde.
|
|
||||||
*/
|
|
||||||
export function resolvePending(
|
|
||||||
cwd: string,
|
|
||||||
agent: string,
|
|
||||||
currentTaskId?: string,
|
|
||||||
): PendingForAgent {
|
|
||||||
// Alias-/Rollen-Auflösung, wenn eine Config da ist — sonst schlichter
|
|
||||||
// Namensvergleich. Ein Check-in darf NIE an fehlender Konfiguration
|
|
||||||
// scheitern: er ist der einzige Kanal eines arbeitenden Agenten.
|
|
||||||
let names: Set<string>;
|
|
||||||
try {
|
|
||||||
names = agentIdentity(cwd, agent).names;
|
|
||||||
} catch {
|
|
||||||
names = new Set([String(agent ?? '').toLowerCase()]);
|
|
||||||
}
|
|
||||||
const isMine = (value: unknown) => names.has(String(value ?? '').toLowerCase());
|
|
||||||
|
|
||||||
const tasks = listTasks(cwd);
|
|
||||||
const pending: PendingForAgent = {
|
|
||||||
waitingTasks: [],
|
|
||||||
awaitingReview: [],
|
|
||||||
answeredAsks: [],
|
|
||||||
unreadCount: 0,
|
|
||||||
messages: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const ask of listAsks(cwd, { status: 'answered' })) {
|
|
||||||
if (!isMine(ask.from)) continue;
|
|
||||||
pending.answeredAsks.push({ id: ask.id, answer: previewOf(ask.answer) });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adressierung EXAKT wie in findAddressedOpenTask (start.ts): Titel-Präfix
|
|
||||||
// `<name>:`, Handoff-Empfänger ODER assignedTo. Prüfte der Check-in nur
|
|
||||||
// assignedTo, meldete der Session-Start "nichts offen" — und der Work-Loop
|
|
||||||
// claimte unmittelbar danach eine Task, die er über das Titel-Präfix findet.
|
|
||||||
// Zwei Wahrheiten über dieselbe Frage sind genau das, was hier nicht passieren darf.
|
|
||||||
let addressedByHandoff = new Set<string>();
|
|
||||||
try {
|
|
||||||
addressedByHandoff = new Set(
|
|
||||||
listHandoffs(cwd)
|
|
||||||
.filter((h) => h.toAgent && isMine(h.toAgent) && h.taskId)
|
|
||||||
.map((h) => String(h.taskId)),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Handoffs nicht lesbar — Präfix/assignedTo tragen weiterhin.
|
|
||||||
}
|
|
||||||
const addressedToMe = (task: { id: string; title?: string; assignedTo?: string }) =>
|
|
||||||
[...names].some((n) => (task.title ?? '').toLowerCase().startsWith(`${n}:`)) ||
|
|
||||||
addressedByHandoff.has(task.id) ||
|
|
||||||
isMine(task.assignedTo);
|
|
||||||
|
|
||||||
for (const task of tasks) {
|
|
||||||
// Nur melden, wenn der Agent die Task nicht ohnehin im Blick hat: loggt er
|
|
||||||
// gerade AUF ihr, wäre der Hinweis Rauschen in jeder einzelnen Antwort.
|
|
||||||
if (
|
|
||||||
task.status === 'in_progress'
|
|
||||||
&& isMine(task.claimedBy ?? task.assignedTo)
|
|
||||||
&& task.id !== currentTaskId
|
|
||||||
) 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentTaskId) {
|
|
||||||
const current = tasks.find((t) => t.id === currentTaskId);
|
|
||||||
if (current && !(current.status === 'in_progress' && isMine(current.claimedBy ?? current.assignedTo))) {
|
|
||||||
pending.interrupted = {
|
|
||||||
taskId: current.id,
|
|
||||||
status: current.status ?? 'unknown',
|
|
||||||
reason:
|
|
||||||
current.status === 'open'
|
|
||||||
? 'Der Task wurde zurückgegeben (reopen) — es liegt neues Review-Feedback vor.'
|
|
||||||
: current.status === 'cancelled'
|
|
||||||
? 'Der Task wurde abgebrochen.'
|
|
||||||
: current.status === 'done'
|
|
||||||
? 'Der Task wurde bereits abgenommen.'
|
|
||||||
: `Der Task steht auf "${current.status}" und ist nicht mehr dein aktiver Claim.`,
|
|
||||||
action:
|
|
||||||
current.status === 'open'
|
|
||||||
? 'STOPP: nicht weiterbauen. Lies den neuesten Handoff zu diesem Task und claime ihn erneut, bevor du weitermachst.'
|
|
||||||
: 'STOPP: nicht weiterbauen. Hol dir mit agenthub_work den aktuellen Stand.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `unread` UND `delivered`: `delivered` heißt nur "einmal ausgeliefert", nicht
|
|
||||||
// "gelesen" — erst read/acked schließt eine Nachricht ab. Filterte man hier
|
|
||||||
// auf `unread`, verschwände jede Nachricht aus dem Rückkanal, sobald sie
|
|
||||||
// einmal irgendwo aufgetaucht ist (z. B. beim Session-Start), ohne dass der
|
|
||||||
// Agent sie je beantwortet hat. Real passiert bei einer Architekten-Korrektur
|
|
||||||
// an kimi: Status `delivered`, im Check-in unsichtbar.
|
|
||||||
const unread = listMessages(cwd).filter(
|
|
||||||
(m) => (m.status === 'unread' || m.status === 'delivered') && isMine(m.to),
|
|
||||||
);
|
|
||||||
pending.unreadCount = unread.length;
|
|
||||||
pending.messages = unread.slice(-MAX_MESSAGE_PREVIEWS).map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
from: m.from,
|
|
||||||
preview: previewOf((m as { text?: string }).text),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
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.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 (parts.length > 0) pending.note = parts.join(' ');
|
|
||||||
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True, wenn wirklich etwas anliegt — sonst lassen Aufrufer den Block weg. */
|
|
||||||
export function hasPending(pending: PendingForAgent): boolean {
|
|
||||||
return (
|
|
||||||
pending.interrupted !== undefined ||
|
|
||||||
pending.heldTask !== undefined ||
|
|
||||||
pending.unreadCount > 0 ||
|
|
||||||
pending.answeredAsks.length > 0 ||
|
|
||||||
pending.waitingTasks.length > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,17 +4,15 @@ import { getNextId } from '../counter.js';
|
|||||||
import { readEntity, writeEntity } from '../files.js';
|
import { readEntity, writeEntity } from '../files.js';
|
||||||
import { HandoffSchema, type Handoff } from '../schema.js';
|
import { HandoffSchema, type Handoff } from '../schema.js';
|
||||||
import { Index } from '../index.js';
|
import { Index } from '../index.js';
|
||||||
import { resolveAgentName } from './identityService.js';
|
|
||||||
|
|
||||||
export function createHandoff(cwd: string, options: Partial<Handoff> = {}): Handoff {
|
export function createHandoff(cwd: string, options: Partial<Handoff> = {}): Handoff {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const toAgent = options.toAgent ? resolveAgentName(cwd, options.toAgent) : undefined;
|
|
||||||
const handoff: Handoff = HandoffSchema.parse({
|
const handoff: Handoff = HandoffSchema.parse({
|
||||||
id: getNextId(cwd, 'handoff'),
|
id: getNextId(cwd, 'handoff'),
|
||||||
fromRole: options.fromRole ?? 'user',
|
fromRole: options.fromRole ?? 'user',
|
||||||
toRole: options.toRole ?? 'user',
|
toRole: options.toRole ?? 'user',
|
||||||
fromAgent: options.fromAgent,
|
fromAgent: options.fromAgent,
|
||||||
toAgent,
|
toAgent: options.toAgent,
|
||||||
taskId: options.taskId,
|
taskId: options.taskId,
|
||||||
summary: options.summary ?? 'Handoff',
|
summary: options.summary ?? 'Handoff',
|
||||||
context: options.context ?? '',
|
context: options.context ?? '',
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
import { loadConfig } from '../config.js';
|
|
||||||
|
|
||||||
export interface AgentIdentity {
|
|
||||||
canonical: string;
|
|
||||||
names: Set<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve an agent, configured alias, or role name to the canonical roster
|
|
||||||
* agent. Projects without an explicit roster keep accepting free-form agent
|
|
||||||
* names for backward compatibility.
|
|
||||||
*/
|
|
||||||
export function resolveAgentName(cwd: string, recipient: string): string {
|
|
||||||
const raw = recipient?.trim();
|
|
||||||
if (!raw) throw new Error('Agent recipient is required');
|
|
||||||
const key = raw.toLowerCase();
|
|
||||||
let config;
|
|
||||||
try {
|
|
||||||
config = loadConfig(cwd);
|
|
||||||
} catch {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = Object.entries(config.roles).find(([name]) => name.toLowerCase() === key);
|
|
||||||
if (role) return role[1].preferredAgent;
|
|
||||||
|
|
||||||
const agents = config.agents;
|
|
||||||
if (!agents || Object.keys(agents).length === 0) return raw;
|
|
||||||
|
|
||||||
for (const [canonical, entry] of Object.entries(agents)) {
|
|
||||||
if (canonical.toLowerCase() === key || entry.aliases.some((alias) => alias.toLowerCase() === key)) {
|
|
||||||
return canonical;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`Unknown agent, alias, or role: "${raw}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All names that should compare equal for task/handoff/inbox matching. */
|
|
||||||
export function agentIdentity(cwd: string, recipient: string): AgentIdentity {
|
|
||||||
const canonical = resolveAgentName(cwd, recipient);
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
const names = new Set<string>([canonical.toLowerCase()]);
|
|
||||||
const entry = config.agents?.[canonical];
|
|
||||||
for (const alias of entry?.aliases ?? []) names.add(alias.toLowerCase());
|
|
||||||
for (const [role, roleConfig] of Object.entries(config.roles)) {
|
|
||||||
if (roleConfig.preferredAgent.toLowerCase() === canonical.toLowerCase()) names.add(role.toLowerCase());
|
|
||||||
}
|
|
||||||
return { canonical, names };
|
|
||||||
}
|
|
||||||
@ -4,7 +4,6 @@ import { getNextId } from '../counter.js';
|
|||||||
import { readEntity, writeEntity } from '../files.js';
|
import { readEntity, writeEntity } from '../files.js';
|
||||||
import { MessageSchema, type Message } from '../schema.js';
|
import { MessageSchema, type Message } from '../schema.js';
|
||||||
import { Index } from '../index.js';
|
import { Index } from '../index.js';
|
||||||
import { agentIdentity, resolveAgentName } from './identityService.js';
|
|
||||||
|
|
||||||
function indexEntryFor(record: Message, filePath: string) {
|
function indexEntryFor(record: Message, filePath: string) {
|
||||||
return {
|
return {
|
||||||
@ -29,11 +28,10 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
|
|||||||
if (!options.text) throw new Error('Message requires text');
|
if (!options.text) throw new Error('Message requires text');
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const to = resolveAgentName(cwd, options.to);
|
|
||||||
const record: Message = MessageSchema.parse({
|
const record: Message = MessageSchema.parse({
|
||||||
id: getNextId(cwd, 'message'),
|
id: getNextId(cwd, 'message'),
|
||||||
from: options.from,
|
from: options.from,
|
||||||
to,
|
to: options.to,
|
||||||
text: options.text,
|
text: options.text,
|
||||||
taskId: options.taskId,
|
taskId: options.taskId,
|
||||||
replyTo: options.replyTo,
|
replyTo: options.replyTo,
|
||||||
@ -53,17 +51,14 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Agent aliases that should see the same inbox. Keep deliberately small. */
|
/** Agent aliases that should see the same inbox. Keep deliberately small. */
|
||||||
export function messageRecipientAliases(cwdOrAgent: string, agent?: string): Set<string> {
|
export function messageRecipientAliases(agent: string): Set<string> {
|
||||||
if (agent) return agentIdentity(cwdOrAgent, agent).names;
|
const key = agent.toLowerCase();
|
||||||
// Remote event filters do not have project config; new writes are already
|
const aliases = new Set([agent, key]);
|
||||||
// canonicalized server-side. Retain the legacy architect pair for old data.
|
|
||||||
const key = cwdOrAgent.toLowerCase();
|
|
||||||
const names = new Set([key]);
|
|
||||||
if (key === 'architect' || key === 'claude') {
|
if (key === 'architect' || key === 'claude') {
|
||||||
names.add('architect');
|
aliases.add('architect');
|
||||||
names.add('claude');
|
aliases.add('claude');
|
||||||
}
|
}
|
||||||
return names;
|
return aliases;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InboxMessage {
|
export interface InboxMessage {
|
||||||
@ -83,18 +78,15 @@ export interface InboxMessage {
|
|||||||
* transitioned to `delivered` (a message the recipient has now been shown) AFTER
|
* transitioned to `delivered` (a message the recipient has now been shown) AFTER
|
||||||
* filtering and BEFORE returning, so the returned rows reflect the new status.
|
* filtering and BEFORE returning, so the returned rows reflect the new status.
|
||||||
* This is agent-scoped only — the architect-wide `listMessages` never mutates.
|
* This is agent-scoped only — the architect-wide `listMessages` never mutates.
|
||||||
*
|
* (The `agenthub work` drainInbox path filters `unreadOnly` BEFORE this mutation
|
||||||
* Nothing auto-marks a message `read`: a delivered-but-unacked message stays
|
* and then marks read, so the delivered intermediate is invisible there — no
|
||||||
* visible in the default inbox view until the recipient explicitly acks/reads
|
* regress and no spin.)
|
||||||
* it (`message ack` / `message read`), so a message surfaced by a background
|
|
||||||
* work loop is never silently lost. Callers that wake on new mail filter
|
|
||||||
* `unreadOnly`, so a delivered message never re-wakes them (no spin).
|
|
||||||
*/
|
*/
|
||||||
export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boolean } = {}): InboxMessage[] {
|
export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boolean } = {}): InboxMessage[] {
|
||||||
const index = new Index(cwd);
|
const index = new Index(cwd);
|
||||||
const all = index.list('message');
|
const all = index.list('message');
|
||||||
index.close();
|
index.close();
|
||||||
const recipients = messageRecipientAliases(cwd, agent);
|
const recipients = messageRecipientAliases(agent);
|
||||||
const filtered = all
|
const filtered = all
|
||||||
.filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase()))
|
.filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase()))
|
||||||
.filter((m) => !opts.unreadOnly || m.status === 'unread');
|
.filter((m) => !opts.unreadOnly || m.status === 'unread');
|
||||||
|
|||||||
@ -53,10 +53,6 @@ export function syncOrgFromFile(cwd: string, from?: string): { count: number; fi
|
|||||||
const org = parseOrg(block);
|
const org = parseOrg(block);
|
||||||
const config = loadConfig(root);
|
const config = loadConfig(root);
|
||||||
config.org = org;
|
config.org = org;
|
||||||
for (const node of org) {
|
|
||||||
if (!node.agent || !node.dispatch || !config.agents?.[node.agent]) continue;
|
|
||||||
config.agents[node.agent].dispatch = node.dispatch;
|
|
||||||
}
|
|
||||||
saveConfig(root, config);
|
saveConfig(root, config);
|
||||||
return { count: org.length, file };
|
return { count: org.length, file };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,211 +0,0 @@
|
|||||||
import { listTasks } from './taskService.js';
|
|
||||||
import { listMessages } from './messageService.js';
|
|
||||||
import { getRoster } from './rosterService.js';
|
|
||||||
import { VERSION } from '../../version.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-agent presence (lastSeen) + the /health report (TSK-0226).
|
|
||||||
*
|
|
||||||
* lastSeen is stamped by the server routes on every announce / claim / review /
|
|
||||||
* log / message action. It is deliberately IN-MEMORY: presence is a live-view
|
|
||||||
* concern of the running hub process, not project data — a restarted hub simply
|
|
||||||
* re-learns presence from the agents' next actions.
|
|
||||||
*
|
|
||||||
* Traffic light per agent:
|
|
||||||
* active (green) — acted within ACTIVE_WINDOW_MS (2 min)
|
|
||||||
* busy (blue) — has an in_progress task (shown as "busy-on-TSK-X")
|
|
||||||
* idle (gray) — seen within STALE_WINDOW_MS (10 min), nothing in flight
|
|
||||||
* stale (red) — not seen for > STALE_WINDOW_MS with an open assignment
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** An agent that acted less than this long ago counts as active. */
|
|
||||||
export const ACTIVE_WINDOW_MS = 2 * 60_000;
|
|
||||||
/** Longer than this without any action ⇒ the agent is stale/offline. */
|
|
||||||
export const STALE_WINDOW_MS = 10 * 60_000;
|
|
||||||
/**
|
|
||||||
* Ab wann gilt ein Agent als DORMANT (der Architekt muss eingreifen)?
|
|
||||||
*
|
|
||||||
* `inLoop` allein taugt dafür NICHT: es ist false, sobald ein Agent einen Task
|
|
||||||
* ausführt — ein hart arbeitender Agent sähe damit genauso aus wie ein toter.
|
|
||||||
* Entscheidend ist, wie lange der letzte Check-in her ist. Alles darunter ist
|
|
||||||
* normale Arbeit und darf keinen Alarm auslösen.
|
|
||||||
*/
|
|
||||||
export const DORMANT_AFTER_MS = 5 * 60_000;
|
|
||||||
|
|
||||||
export type AgentLight = 'active' | 'busy' | 'idle' | 'stale';
|
|
||||||
|
|
||||||
export interface AgentHealth {
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
dispatch: 'loop' | 'architect';
|
|
||||||
state: AgentLight;
|
|
||||||
/** Busy-on task (in_progress) or — for stale agents — the waiting open task. */
|
|
||||||
taskId?: string;
|
|
||||||
waitingTaskId?: string;
|
|
||||||
lastSeen?: string;
|
|
||||||
lastSeenAgoSec?: number;
|
|
||||||
inLoop: boolean;
|
|
||||||
loopSince?: string;
|
|
||||||
loopSinceAgoSec?: number;
|
|
||||||
loopExitAt?: string;
|
|
||||||
loopExitAgoSec?: number;
|
|
||||||
loopExitReason?: string;
|
|
||||||
/**
|
|
||||||
* TSK-0274: offene Tasks, die an diesen Agenten adressiert sind — also
|
|
||||||
* handlungsrelevante Arbeit, die er noch nicht aufgenommen hat.
|
|
||||||
*
|
|
||||||
* Bewusst OHNE die ungelesenen Nachrichten: der historische Nachrichten-
|
|
||||||
* Rückstau (einzelne Agenten haben dreistellige Altbestände) würde das
|
|
||||||
* Signal vollständig erschlagen. Der Rückstau steht separat in `unreadCount`.
|
|
||||||
*/
|
|
||||||
pendingCount: number;
|
|
||||||
/** Ungelesene Nachrichten — separat, weil überwiegend Altbestand. */
|
|
||||||
unreadCount: number;
|
|
||||||
/**
|
|
||||||
* Sekunden seit dem letzten Check-in, WÄHREND der Agent an einem Task
|
|
||||||
* arbeitet — also die Zeitspanne, in der er nichts mitbekommen konnte.
|
|
||||||
* Nur gesetzt, wenn er tatsächlich einen Task hält.
|
|
||||||
*/
|
|
||||||
deafForSec?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HealthReport {
|
|
||||||
status: 'ok';
|
|
||||||
version: string;
|
|
||||||
startedAt: string;
|
|
||||||
uptimeSec: number;
|
|
||||||
counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number };
|
|
||||||
agents: AgentHealth[];
|
|
||||||
indexErrors?: Array<{ filePath: string; error: string; at: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastSeen = new Map<string, number>();
|
|
||||||
const loops = new Map<string, { since?: number; exitAt?: number; exitReason?: string }>();
|
|
||||||
|
|
||||||
/** Stamp an agent's lastSeen (called by the routes on agent actions). */
|
|
||||||
export function stampSeen(agent: string, at: number = Date.now()): void {
|
|
||||||
const name = agent?.trim();
|
|
||||||
if (!name) return;
|
|
||||||
lastSeen.set(name, at);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function enterLoop(agent: string, at: number = Date.now()): void {
|
|
||||||
const name = agent?.trim();
|
|
||||||
if (!name) return;
|
|
||||||
lastSeen.set(name, at);
|
|
||||||
loops.set(name, { since: at });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function leaveLoop(agent: string, reason: string, at: number = Date.now()): void {
|
|
||||||
const name = agent?.trim();
|
|
||||||
if (!name) return;
|
|
||||||
lastSeen.set(name, at);
|
|
||||||
loops.set(name, { exitAt: at, exitReason: reason || 'ended' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAgentInLoop(agent: string): boolean {
|
|
||||||
return loops.get(agent)?.since !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function agentLoopStatus(agent: string): 'active' | 'inactive' | 'unknown' {
|
|
||||||
const loop = loops.get(agent);
|
|
||||||
if (!loop) return 'unknown';
|
|
||||||
return loop.since !== undefined ? 'active' : 'inactive';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Test hook: drop all presence state. */
|
|
||||||
export function resetPresence(): void {
|
|
||||||
lastSeen.clear();
|
|
||||||
loops.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One agent's traffic light, derived from presence + its task load. */
|
|
||||||
export function agentLight(
|
|
||||||
name: string,
|
|
||||||
tasks: Array<{ id: string; status?: string; assignedTo?: string; claimedBy?: string }>,
|
|
||||||
now: number = Date.now(),
|
|
||||||
): Omit<AgentHealth, 'name' | 'role' | 'dispatch'> {
|
|
||||||
const seen = lastSeen.get(name);
|
|
||||||
const busy = tasks.find((t) => t.status === 'in_progress' && (t.assignedTo === name || t.claimedBy === name));
|
|
||||||
const waiting = tasks.find((t) => t.status === 'open' && t.assignedTo === name);
|
|
||||||
const seenAgo = seen === undefined ? undefined : now - seen;
|
|
||||||
const loop = loops.get(name);
|
|
||||||
|
|
||||||
let state: AgentLight;
|
|
||||||
if (seenAgo !== undefined && seenAgo < ACTIVE_WINDOW_MS) {
|
|
||||||
state = busy ? 'busy' : 'active';
|
|
||||||
} else if (busy) {
|
|
||||||
state = 'busy';
|
|
||||||
} else if (seenAgo !== undefined && seenAgo < STALE_WINDOW_MS) {
|
|
||||||
state = 'idle';
|
|
||||||
} else if (waiting) {
|
|
||||||
state = 'stale';
|
|
||||||
} else {
|
|
||||||
state = 'idle';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
taskId: busy?.id ?? (state === 'stale' ? waiting?.id : undefined),
|
|
||||||
waitingTaskId: waiting?.id,
|
|
||||||
lastSeen: seen === undefined ? undefined : new Date(seen).toISOString(),
|
|
||||||
lastSeenAgoSec: seenAgo === undefined ? undefined : Math.max(0, Math.round(seenAgo / 1000)),
|
|
||||||
inLoop: loop?.since !== undefined,
|
|
||||||
loopSince: loop?.since === undefined ? undefined : new Date(loop.since).toISOString(),
|
|
||||||
loopSinceAgoSec: loop?.since === undefined ? undefined : Math.max(0, Math.round((now - loop.since) / 1000)),
|
|
||||||
loopExitAt: loop?.exitAt === undefined ? undefined : new Date(loop.exitAt).toISOString(),
|
|
||||||
loopExitAgoSec: loop?.exitAt === undefined ? undefined : Math.max(0, Math.round((now - loop.exitAt) / 1000)),
|
|
||||||
loopExitReason: loop?.exitReason,
|
|
||||||
pendingCount: 0, // von computeHealth() befüllt (braucht die Task-/Nachrichtenliste)
|
|
||||||
unreadCount: 0,
|
|
||||||
deafForSec: busy && seenAgo !== undefined ? Math.max(0, Math.round(seenAgo / 1000)) : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compact hub health: status + version + uptime + counts + per-agent lights. */
|
|
||||||
export function computeHealth(cwd: string, startedAtMs: number, now: number = Date.now()): HealthReport {
|
|
||||||
const tasks = listTasks(cwd);
|
|
||||||
const roster = getRoster(cwd);
|
|
||||||
const roleByName = new Map(roster.map((r) => [r.name, r.role]));
|
|
||||||
const dispatchByName = new Map(roster.map((r) => [r.name, r.dispatch]));
|
|
||||||
// Roster agents PLUS anyone who only ever announced themselves (presence-only).
|
|
||||||
const names = new Set<string>([...roster.map((r) => r.name), ...lastSeen.keys()]);
|
|
||||||
|
|
||||||
const messages = listMessages(cwd);
|
|
||||||
const unreadByAgent = new Map<string, number>();
|
|
||||||
for (const m of messages) {
|
|
||||||
if (m.status !== 'unread') continue;
|
|
||||||
const to = String(m.to ?? '');
|
|
||||||
if (to) unreadByAgent.set(to, (unreadByAgent.get(to) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const agents: AgentHealth[] = Array.from(names)
|
|
||||||
.sort((a, b) => a.localeCompare(b))
|
|
||||||
.map((name) => {
|
|
||||||
const light = agentLight(name, tasks, now);
|
|
||||||
const openForAgent = tasks.filter((t) => t.status === 'open' && t.assignedTo === name).length;
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
role: roleByName.get(name) ?? 'implementer',
|
|
||||||
dispatch: dispatchByName.get(name) ?? 'loop',
|
|
||||||
...light,
|
|
||||||
pendingCount: openForAgent,
|
|
||||||
unreadCount: unreadByAgent.get(name) ?? 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'ok',
|
|
||||||
version: VERSION,
|
|
||||||
startedAt: new Date(startedAtMs).toISOString(),
|
|
||||||
uptimeSec: Math.max(0, Math.round((now - startedAtMs) / 1000)),
|
|
||||||
counts: {
|
|
||||||
tasks: tasks.length,
|
|
||||||
open: tasks.filter((t) => t.status === 'open').length,
|
|
||||||
inProgress: tasks.filter((t) => t.status === 'in_progress').length,
|
|
||||||
review: tasks.filter((t) => t.status === 'review').length,
|
|
||||||
unreadMessages: listMessages(cwd).filter((m) => m.status === 'unread').length,
|
|
||||||
},
|
|
||||||
agents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
import { loadConfig, saveConfig } from '../config.js';
|
|
||||||
import { Role, type Config } from '../schema.js';
|
|
||||||
|
|
||||||
function fallbackRole(config: Config, agent: string, excludingRole: string): string {
|
|
||||||
const preferred = Object.entries(config.roles)
|
|
||||||
.find(([role, spec]) => role !== excludingRole && spec.preferredAgent === agent);
|
|
||||||
return preferred?.[0] ?? 'implementer';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change the preferred agent for a role and keep the named roster coherent.
|
|
||||||
*
|
|
||||||
* `roles.*.preferredAgent` is the routing authority (asks, watchdog, start),
|
|
||||||
* while `agents.*.role` drives health/board/MCP role discovery. Updating only
|
|
||||||
* one side makes a reachable server tell the newly selected architect that it
|
|
||||||
* is still an implementer, so both are changed atomically.
|
|
||||||
*/
|
|
||||||
export function setPreferredAgent(
|
|
||||||
cwd: string,
|
|
||||||
role: string,
|
|
||||||
agent: string,
|
|
||||||
): { role: string; agent: string; previousAgent: string } {
|
|
||||||
const nextAgent = agent.trim();
|
|
||||||
if (!nextAgent) throw new Error('agent must not be empty');
|
|
||||||
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
const parsedRole = Role.parse(role);
|
|
||||||
const roleSpec = config.roles[parsedRole];
|
|
||||||
if (!roleSpec) throw new Error(`Unknown role: ${role}`);
|
|
||||||
const previousAgent = roleSpec.preferredAgent;
|
|
||||||
|
|
||||||
config.roles[parsedRole] = { ...roleSpec, preferredAgent: nextAgent };
|
|
||||||
if (config.agents) {
|
|
||||||
const target = config.agents[nextAgent];
|
|
||||||
if (!target) throw new Error(`Unknown agent: ${nextAgent}`);
|
|
||||||
|
|
||||||
config.agents[nextAgent] = { ...target, role: parsedRole };
|
|
||||||
if (previousAgent !== nextAgent) {
|
|
||||||
const previous = config.agents[previousAgent];
|
|
||||||
if (previous?.role === role) {
|
|
||||||
config.agents[previousAgent] = {
|
|
||||||
...previous,
|
|
||||||
role: Role.parse(fallbackRole(config, previousAgent, parsedRole)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveConfig(cwd, config);
|
|
||||||
return { role: parsedRole, agent: nextAgent, previousAgent };
|
|
||||||
}
|
|
||||||
@ -10,7 +10,6 @@ export interface RosterEntry {
|
|||||||
kind?: string;
|
kind?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
budgetEur?: number;
|
budgetEur?: number;
|
||||||
dispatch: 'loop' | 'architect';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Infer a provider ("kind") from an agent name when the roster doesn't say. */
|
/** Infer a provider ("kind") from an agent name when the roster doesn't say. */
|
||||||
@ -39,22 +38,13 @@ export function getRoster(cwd: string): RosterEntry[] {
|
|||||||
const config = loadConfig(cwd);
|
const config = loadConfig(cwd);
|
||||||
|
|
||||||
if (config.agents && Object.keys(config.agents).length > 0) {
|
if (config.agents && Object.keys(config.agents).length > 0) {
|
||||||
const preferredRole = new Map<string, string>();
|
|
||||||
for (const [role, spec] of Object.entries(config.roles)) {
|
|
||||||
// Architect is authoritative when one agent is preferred for several
|
|
||||||
// roles (the default Claude config also prefers it as reviewer).
|
|
||||||
if (!preferredRole.has(spec.preferredAgent) || role === 'architect') {
|
|
||||||
preferredRole.set(spec.preferredAgent, role);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Object.entries(config.agents).map(([name, a]) => ({
|
return Object.entries(config.agents).map(([name, a]) => ({
|
||||||
name,
|
name,
|
||||||
role: preferredRole.get(name) ?? a.role,
|
role: a.role,
|
||||||
model: a.model,
|
model: a.model,
|
||||||
kind: a.kind ?? inferKind(name),
|
kind: a.kind ?? inferKind(name),
|
||||||
description: a.description,
|
description: a.description,
|
||||||
budgetEur: a.budgetEur,
|
budgetEur: a.budgetEur,
|
||||||
dispatch: a.dispatch,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +60,6 @@ export function getRoster(cwd: string): RosterEntry[] {
|
|||||||
|
|
||||||
return Array.from(names).map((name) => {
|
return Array.from(names).map((name) => {
|
||||||
const kind = inferKind(name);
|
const kind = inferKind(name);
|
||||||
return { name, role: roleByAgent.get(name) ?? 'implementer', kind, model: inferModel(kind), dispatch: 'loop' };
|
return { name, role: roleByAgent.get(name) ?? 'implementer', kind, model: inferModel(kind) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,11 +6,9 @@ import { readEntity, writeEntity } from '../files.js';
|
|||||||
import { TaskSchema, type Task } from '../schema.js';
|
import { TaskSchema, type Task } from '../schema.js';
|
||||||
import { Index } from '../index.js';
|
import { Index } from '../index.js';
|
||||||
import { loadConfig } from '../config.js';
|
import { loadConfig } from '../config.js';
|
||||||
import { resolveAgentName } from './identityService.js';
|
|
||||||
|
|
||||||
export function createTask(cwd: string, options: Partial<Task> = {}): Task {
|
export function createTask(cwd: string, options: Partial<Task> = {}): Task {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const assignedTo = options.assignedTo ? resolveAgentName(cwd, options.assignedTo) : undefined;
|
|
||||||
const task: Task = TaskSchema.parse({
|
const task: Task = TaskSchema.parse({
|
||||||
id: getNextId(cwd, 'task'),
|
id: getNextId(cwd, 'task'),
|
||||||
title: options.title ?? 'Untitled',
|
title: options.title ?? 'Untitled',
|
||||||
@ -18,8 +16,8 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task {
|
|||||||
status: 'open',
|
status: 'open',
|
||||||
priority: options.priority ?? 'medium',
|
priority: options.priority ?? 'medium',
|
||||||
role: options.role,
|
role: options.role,
|
||||||
assignedTo,
|
assignedTo: options.assignedTo,
|
||||||
claimedBy: options.claimedBy ? resolveAgentName(cwd, options.claimedBy) : undefined,
|
claimedBy: options.claimedBy,
|
||||||
reviewer: options.reviewer,
|
reviewer: options.reviewer,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@ -35,50 +33,6 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task {
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record work completed outside AgentHub without fabricating lifecycle events. */
|
|
||||||
export function recordExternalTask(
|
|
||||||
cwd: string,
|
|
||||||
options: Pick<Task, 'title'> & Partial<Task> & { doneBy: string },
|
|
||||||
): Task {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const agent = resolveAgentName(cwd, options.doneBy);
|
|
||||||
const task: Task = TaskSchema.parse({
|
|
||||||
id: getNextId(cwd, 'task'),
|
|
||||||
title: options.title,
|
|
||||||
description: options.description ?? '',
|
|
||||||
status: 'done',
|
|
||||||
priority: options.priority ?? 'medium',
|
|
||||||
role: options.role ?? 'implementer',
|
|
||||||
assignedTo: agent,
|
|
||||||
claimedBy: agent,
|
|
||||||
doneBy: agent,
|
|
||||||
doneTokens: options.doneTokens,
|
|
||||||
doneDuration: options.doneDuration,
|
|
||||||
origin: 'external',
|
|
||||||
recordedAt: now,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
|
||||||
const filePath = join(getEntityDir(cwd, 'tasks'), `${task.id}.md`);
|
|
||||||
writeEntity(filePath, task, `# ${task.title}\n\n${task.description}\n\n> Nachgetragen: außerhalb von AgentHub erledigt durch ${agent}.`);
|
|
||||||
const index = new Index(cwd);
|
|
||||||
index.upsert(toIndexEntry(task, filePath));
|
|
||||||
index.close();
|
|
||||||
return task;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Architect explicitly starts a session-less roster agent for an open task. */
|
|
||||||
export function dispatchTask(cwd: string, id: string, agentName: string): Task {
|
|
||||||
const agent = resolveAgentName(cwd, agentName);
|
|
||||||
const roster = loadConfig(cwd).agents?.[agent];
|
|
||||||
if (roster?.dispatch !== 'architect') {
|
|
||||||
throw new Error(`${agent} is not architect-dispatched`);
|
|
||||||
}
|
|
||||||
const { task } = getTask(cwd, id);
|
|
||||||
if (task.status !== 'open') throw new Error(`Task ${id} cannot be dispatched from ${task.status}`);
|
|
||||||
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agent, claimedBy: agent });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listTasks(cwd: string, filters?: { status?: string; role?: string }): ReturnType<Index['list']> {
|
export function listTasks(cwd: string, filters?: { status?: string; role?: string }): ReturnType<Index['list']> {
|
||||||
const index = new Index(cwd);
|
const index = new Index(cwd);
|
||||||
const tasks = index.list('task', filters);
|
const tasks = index.list('task', filters);
|
||||||
@ -118,7 +72,6 @@ export function updateTask(cwd: string, id: string, patch: Partial<Task>): Task
|
|||||||
* then sees `in_progress` and is refused.
|
* then sees `in_progress` and is refused.
|
||||||
*/
|
*/
|
||||||
export function claimTask(cwd: string, id: string, agentName: string): Task {
|
export function claimTask(cwd: string, id: string, agentName: string): Task {
|
||||||
agentName = resolveAgentName(cwd, agentName);
|
|
||||||
const { task } = getTask(cwd, id);
|
const { task } = getTask(cwd, id);
|
||||||
// Idempotent: the same agent re-claiming its own in-progress task is a no-op.
|
// Idempotent: the same agent re-claiming its own in-progress task is a no-op.
|
||||||
if (task.status === 'in_progress' && task.claimedBy === agentName) {
|
if (task.status === 'in_progress' && task.claimedBy === agentName) {
|
||||||
@ -129,39 +82,6 @@ export function claimTask(cwd: string, id: string, agentName: string): Task {
|
|||||||
`Task ${id} cannot be claimed — status is "${task.status}"${task.claimedBy ? ` (claimed by ${task.claimedBy})` : ''}.`,
|
`Task ${id} cannot be claimed — status is "${task.status}"${task.claimedBy ? ` (claimed by ${task.claimedBy})` : ''}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Single-claim guard (TSK-0237): an agent holds at most ONE in_progress task.
|
|
||||||
// Second line of defense behind the work loop's own selection — a direct
|
|
||||||
// claim (CLI/MCP/board drag) must not bypass it either. The architect is
|
|
||||||
// exempt (coordinates several threads); there is no --force for agents.
|
|
||||||
// An uninitialized project (no config, e.g. bare service-level usage) simply
|
|
||||||
// has no architect exemption — the guard still applies.
|
|
||||||
let architect: string | undefined;
|
|
||||||
try {
|
|
||||||
architect = loadConfig(cwd).roles.architect?.preferredAgent;
|
|
||||||
} catch {
|
|
||||||
architect = undefined;
|
|
||||||
}
|
|
||||||
if (agentName !== architect) {
|
|
||||||
// DEC-0035: `review` bindet den Agenten genauso wie `in_progress` — er ist
|
|
||||||
// erst wieder frei, wenn der Architekt abgenommen (done) oder
|
|
||||||
// zurückgewiesen (reopen) hat. `agenthub task dispatch` bleibt der bewusste
|
|
||||||
// Architekten-Override.
|
|
||||||
const held = listTasks(cwd).find(
|
|
||||||
(t) =>
|
|
||||||
(t.status === 'in_progress' || t.status === 'review') &&
|
|
||||||
t.id !== id &&
|
|
||||||
(t.claimedBy === agentName || (!t.claimedBy && t.assignedTo === agentName)),
|
|
||||||
);
|
|
||||||
if (held) {
|
|
||||||
const what = held.status === 'review'
|
|
||||||
? `is waiting for architect review on ${held.id}`
|
|
||||||
: `already holds an in_progress claim on ${held.id}`;
|
|
||||||
throw new Error(
|
|
||||||
`${agentName} ${what} — one task at a time: wait for the architect to approve (done) or reopen ${held.id} ` +
|
|
||||||
`before claiming ${id}. Stay in agenthub_work meanwhile; you will be woken.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName, claimedBy: agentName });
|
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName, claimedBy: agentName });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,16 +90,9 @@ export function doneTask(
|
|||||||
id: string,
|
id: string,
|
||||||
meta?: { doneBy?: string; doneTokens?: number; doneDuration?: number },
|
meta?: { doneBy?: string; doneTokens?: number; doneDuration?: number },
|
||||||
): Task {
|
): Task {
|
||||||
const { task } = getTask(cwd, id);
|
|
||||||
if (task.status !== 'review') {
|
|
||||||
throw new Error(`Task ${id} cannot transition ${task.status} → done; expected review → done`);
|
|
||||||
}
|
|
||||||
if (!task.claimedBy) {
|
|
||||||
throw new Error(`Task ${id} cannot be completed without a known claimedBy implementer`);
|
|
||||||
}
|
|
||||||
return updateTask(cwd, id, {
|
return updateTask(cwd, id, {
|
||||||
status: 'done',
|
status: 'done',
|
||||||
doneBy: meta?.doneBy ?? task.claimedBy,
|
doneBy: meta?.doneBy,
|
||||||
doneTokens: meta?.doneTokens,
|
doneTokens: meta?.doneTokens,
|
||||||
doneDuration: meta?.doneDuration,
|
doneDuration: meta?.doneDuration,
|
||||||
});
|
});
|
||||||
@ -187,12 +100,6 @@ export function doneTask(
|
|||||||
|
|
||||||
export function reviewTask(cwd: string, id: string, reviewer?: string): Task {
|
export function reviewTask(cwd: string, id: string, reviewer?: string): Task {
|
||||||
const { task } = getTask(cwd, id);
|
const { task } = getTask(cwd, id);
|
||||||
if (task.status !== 'in_progress') {
|
|
||||||
throw new Error(`Task ${id} cannot transition ${task.status} → review; expected in_progress → review`);
|
|
||||||
}
|
|
||||||
if (!task.claimedBy) {
|
|
||||||
throw new Error(`Task ${id} cannot enter review without a known claimedBy implementer`);
|
|
||||||
}
|
|
||||||
const resolvedReviewer = reviewer?.trim() || getPreferredReviewer(cwd);
|
const resolvedReviewer = reviewer?.trim() || getPreferredReviewer(cwd);
|
||||||
const patch: Partial<Task> = { status: 'review' };
|
const patch: Partial<Task> = { status: 'review' };
|
||||||
if (resolvedReviewer && resolvedReviewer !== task.assignedTo) {
|
if (resolvedReviewer && resolvedReviewer !== task.assignedTo) {
|
||||||
@ -202,33 +109,11 @@ export function reviewTask(cwd: string, id: string, reviewer?: string): Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function cancelTask(cwd: string, id: string): Task {
|
export function cancelTask(cwd: string, id: string): Task {
|
||||||
const { task } = getTask(cwd, id);
|
|
||||||
if (task.status === 'done' || task.status === 'cancelled') {
|
|
||||||
throw new Error(`Task ${id} is terminal (${task.status}) and cannot transition to cancelled`);
|
|
||||||
}
|
|
||||||
return updateTask(cwd, id, { status: 'cancelled' });
|
return updateTask(cwd, id, { status: 'cancelled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Zurück auf `open`. Zwei legitime Fälle, beide mit demselben Ergebnis
|
|
||||||
* (Claim abgeräumt, `assignedTo` bleibt als Adressierung stehen):
|
|
||||||
*
|
|
||||||
* - `review → open` — der Architekt weist die Lieferung zurück.
|
|
||||||
* - `in_progress → open` — der Architekt entzieht eine laufende Arbeit
|
|
||||||
* (Agent hängt, Task wird umgehängt, Priorität gedreht). Ohne diesen Weg
|
|
||||||
* ließ sich eine festhängende Task gar nicht mehr befreien (selbst erlebt
|
|
||||||
* beim Umhängen von TSK-0273): reopen scheiterte mit 400, und der
|
|
||||||
* Ein-Task-Guard blockierte den Agenten dauerhaft.
|
|
||||||
*
|
|
||||||
* Der betroffene Agent erfährt davon über den Check-in-Kanal (checkinService):
|
|
||||||
* sein nächster `agenthub_task_log` liefert `pending.interrupted` zurück.
|
|
||||||
*/
|
|
||||||
export function reopenTask(cwd: string, id: string): Task {
|
export function reopenTask(cwd: string, id: string): Task {
|
||||||
const { task } = getTask(cwd, id);
|
return updateTask(cwd, id, { status: 'open' });
|
||||||
if (task.status !== 'review' && task.status !== 'in_progress') {
|
|
||||||
throw new Error(`Task ${id} cannot transition ${task.status} → open; expected review → open or in_progress → open`);
|
|
||||||
}
|
|
||||||
return updateTask(cwd, id, { status: 'open', claimedBy: undefined });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -256,12 +141,7 @@ export function deleteTask(cwd: string, id: string): { id: string } {
|
|||||||
* a specific agent and have a waiting daemon pick it up.
|
* a specific agent and have a waiting daemon pick it up.
|
||||||
*/
|
*/
|
||||||
export function assignTask(cwd: string, id: string, agentName: string): Task {
|
export function assignTask(cwd: string, id: string, agentName: string): Task {
|
||||||
const resolved = resolveAgentName(cwd, agentName);
|
return updateTask(cwd, id, { assignedTo: agentName });
|
||||||
const { task } = getTask(cwd, id);
|
|
||||||
return updateTask(cwd, id, {
|
|
||||||
assignedTo: resolved,
|
|
||||||
claimedBy: task.status === 'in_progress' ? resolved : undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPreferredReviewer(cwd: string): string | undefined {
|
function getPreferredReviewer(cwd: string): string | undefined {
|
||||||
@ -272,7 +152,7 @@ function getPreferredReviewer(cwd: string): string | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toIndexEntry(task: Task, filePath: string) {
|
function toIndexEntry(task: Task, filePath: string) {
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
type: 'task',
|
type: 'task',
|
||||||
|
|||||||
@ -59,45 +59,6 @@ export function startDiscoveryBroadcaster(getServerUrl: string | (() => string),
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cheap reachability probe for a configured server URL: GET /status with a
|
|
||||||
* short timeout. Used to detect a stale saved address (e.g. agenthub.local
|
|
||||||
* after a network change) before a remote command hard-fails.
|
|
||||||
*/
|
|
||||||
export async function probeServer(url: string, timeoutMs = 1000): Promise<boolean> {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
try {
|
|
||||||
const res = await fetch(new URL('/status', url).toString(), { signal: controller.signal });
|
|
||||||
return res.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Self-heal a stale configured server URL: probe it, and if it is unreachable
|
|
||||||
* fall back to LAN discovery. Returns the URL to use for the call — the
|
|
||||||
* configured one when it answers, otherwise the discovered one, otherwise
|
|
||||||
* `undefined` (nothing reachable). Probe/discovery are injectable for tests.
|
|
||||||
*/
|
|
||||||
export async function resolveReachableServerUrl(
|
|
||||||
configuredUrl: string,
|
|
||||||
options: {
|
|
||||||
probe?: (url: string) => Promise<boolean>;
|
|
||||||
discover?: (timeoutMs?: number) => Promise<string | undefined>;
|
|
||||||
discoverTimeoutMs?: number;
|
|
||||||
} = {},
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
const probe = options.probe ?? probeServer;
|
|
||||||
if (await probe(configuredUrl)) return configuredUrl;
|
|
||||||
const discover = options.discover ?? discoverServer;
|
|
||||||
const discovered = await discover(options.discoverTimeoutMs ?? 2000);
|
|
||||||
return discovered && discovered !== configuredUrl ? discovered : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function discoverServer(timeoutMs = 3000, port = DISCOVERY_PORT): Promise<string | undefined> {
|
export function discoverServer(timeoutMs = 3000, port = DISCOVERY_PORT): Promise<string | undefined> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const socket = dgram.createSocket('udp4');
|
const socket = dgram.createSocket('udp4');
|
||||||
|
|||||||
@ -23,11 +23,10 @@ function resolveUrl(root: string): string {
|
|||||||
return serverUrl ?? 'http://127.0.0.1:3377';
|
return serverUrl ?? 'http://127.0.0.1:3377';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function installMcp(cwd: string, opts: { print?: boolean; agent?: string } = {}): void {
|
export function installMcp(cwd: string, opts: { print?: boolean } = {}): void {
|
||||||
const root = findProjectRoot(cwd) ?? cwd;
|
const root = findProjectRoot(cwd) ?? cwd;
|
||||||
const url = resolveUrl(root);
|
const url = resolveUrl(root);
|
||||||
const args = ['mcp', ...(opts.agent ? ['--agent', opts.agent] : [])];
|
const serverDef = { command: 'agenthub', args: ['mcp'], env: { AGENTHUB_SERVER: url } };
|
||||||
const serverDef = { command: 'agenthub', args, env: { AGENTHUB_SERVER: url } };
|
|
||||||
const out = (s = '') => process.stdout.write(s + '\n');
|
const out = (s = '') => process.stdout.write(s + '\n');
|
||||||
|
|
||||||
out();
|
out();
|
||||||
@ -63,11 +62,11 @@ export function installMcp(cwd: string, opts: { print?: boolean; agent?: string
|
|||||||
out('── Codex (~/.codex/config.toml) ──');
|
out('── Codex (~/.codex/config.toml) ──');
|
||||||
out(' [mcp_servers.agenthub]');
|
out(' [mcp_servers.agenthub]');
|
||||||
out(' command = "agenthub"');
|
out(' command = "agenthub"');
|
||||||
out(` args = ${JSON.stringify(args)}`);
|
out(' args = ["mcp"]');
|
||||||
out(` env = { AGENTHUB_SERVER = "${url}" }`);
|
out(` env = { AGENTHUB_SERVER = "${url}" }`);
|
||||||
out('');
|
out('');
|
||||||
out('── Kimi (its MCP config) ──');
|
out('── Kimi (its MCP config) ──');
|
||||||
out(` stdio server — command: agenthub args: ${JSON.stringify(args)} env: AGENTHUB_SERVER=${url}`);
|
out(` stdio server — command: agenthub args: ["mcp"] env: AGENTHUB_SERVER=${url}`);
|
||||||
out('');
|
out('');
|
||||||
out('On Windows use the Mac LAN IP (e.g. http://192.168.178.30:3377). Requires agenthub >= 0.7.0.');
|
out('On Windows use the Mac LAN IP (e.g. http://192.168.178.30:3377). Requires agenthub >= 0.7.0.');
|
||||||
out('');
|
out('');
|
||||||
|
|||||||
@ -1,158 +0,0 @@
|
|||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
||||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import { parseSSEBuffer } from '../cli/commands/watch.js';
|
|
||||||
import { remoteClient } from '../cli/remoteClient.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Echter Push an den Agenten über MCP — ohne blockierenden Tool-Call.
|
|
||||||
*
|
|
||||||
* WARUM DAS HIER STEHT (die Lehre aus einem ganzen Tag):
|
|
||||||
* Bisher konnte ein Agent nur empfangen, während er in `agenthub_work`
|
|
||||||
* blockierte. Kehrte der Aufruf leer zurück und das Modell startete ihn nicht
|
|
||||||
* neu, war der Agent unerreichbar — kein Reopen, keine Nachricht kam an, und
|
|
||||||
* ein Mensch musste ihn anstoßen. Wir haben um diese Tatsache herum gebaut
|
|
||||||
* (Watchdog, Check-in-Kanal, längere Timeouts) statt sie aufzulösen.
|
|
||||||
*
|
|
||||||
* MCP kann das von Haus aus: der Server darf dem Client JEDERZEIT
|
|
||||||
* `notifications/message` schicken. Unsere Bridge ist ohnehin ein langlebiger
|
|
||||||
* Prozess pro Agent — sie hängt sich dauerhaft an den SSE-Stream des Hubs und
|
|
||||||
* reicht relevante Ereignisse als MCP-Notification hoch. Kein Blockieren,
|
|
||||||
* kein Timeout, keine Loop-Disziplin nötig.
|
|
||||||
*
|
|
||||||
* ⚠️ VORAUSSETZUNG: Der Server MUSS die `logging`-Capability deklarieren —
|
|
||||||
* `sendLoggingMessage` prüft `_capabilities.logging` und verwirft die
|
|
||||||
* Notification sonst STILL. Genau daran wäre dieser Weg unbemerkt gescheitert.
|
|
||||||
*
|
|
||||||
* ⚠️ OFFEN (client-abhängig, nicht von uns entscheidbar): ob der jeweilige
|
|
||||||
* CLI-Host die Notification dem Modell zeigt. Das ist empirisch zu prüfen und
|
|
||||||
* darf nicht behauptet werden. Deshalb bleibt der Check-in-Kanal als Netz
|
|
||||||
* bestehen — dieser Push ist die Verbesserung, nicht der alleinige Verlass.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Reconnect-Backoff, wenn der SSE-Stream abreißt (Hub-Neustart o. Ä.). */
|
|
||||||
const RECONNECT_DELAYS_MS = [1000, 3000, 8000];
|
|
||||||
|
|
||||||
interface PushState {
|
|
||||||
agent: string;
|
|
||||||
names: Set<string>;
|
|
||||||
stop: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let active: PushState | undefined;
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Betrifft dieses Event den Agenten, für den wir pushen? */
|
|
||||||
function relevantFor(
|
|
||||||
event: { type?: string; action?: string; assignedTo?: string; status?: string },
|
|
||||||
names: Set<string>,
|
|
||||||
): boolean {
|
|
||||||
const target = String(event.assignedTo ?? '').toLowerCase();
|
|
||||||
if (target && names.has(target)) return true;
|
|
||||||
// Ohne Adressat ist ein Task-Event nur interessant, wenn es ein Reopen sein
|
|
||||||
// könnte — der Claim wird dabei abgeräumt, der Adressat bleibt aber stehen,
|
|
||||||
// also greift oben bereits `assignedTo`. Alles andere ignorieren wir bewusst:
|
|
||||||
// Rauschen entwertet den Kanal (Lehre aus dem Watchdog-Spam).
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function describe(event: { type?: string; action?: string; id?: string; status?: string }): string {
|
|
||||||
const id = event.id ?? '?';
|
|
||||||
if (event.type === 'message') return `Neue Nachricht ${id} für dich — agenthub_inbox lesen.`;
|
|
||||||
if (event.type === 'ask') return `Deine Frage ${id} wurde beantwortet — agenthub_ask_list.`;
|
|
||||||
if (event.status === 'open') return `${id} wartet auf dich (neu oder zurückgegeben) — agenthub_work aufrufen.`;
|
|
||||||
if (event.status === 'cancelled') return `${id} wurde abgebrochen — STOPP, nicht weiterbauen.`;
|
|
||||||
return `${id}: ${event.type}/${event.action} (${event.status ?? '—'}).`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bindet die Bridge an einen Agenten und beginnt zu pushen. Idempotent:
|
|
||||||
* derselbe Agent startet den Stream nicht doppelt, ein anderer löst den alten ab.
|
|
||||||
*/
|
|
||||||
export function bindPushChannel(server: McpServer, serverUrl: string, agent: string): void {
|
|
||||||
const name = agent.trim().toLowerCase();
|
|
||||||
if (!name || active?.agent === name) return;
|
|
||||||
active?.stop();
|
|
||||||
|
|
||||||
let stopped = false;
|
|
||||||
let controller: AbortController | undefined;
|
|
||||||
let lastEventId: number | undefined;
|
|
||||||
const state: PushState = {
|
|
||||||
agent: name,
|
|
||||||
names: new Set([name]),
|
|
||||||
stop: () => {
|
|
||||||
stopped = true;
|
|
||||||
controller?.abort();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
active = state;
|
|
||||||
|
|
||||||
// DIAGNOSE: Was deklariert der CLI-Host ueberhaupt? `sendLoggingMessage`
|
|
||||||
// nuetzt nichts, wenn der Client `logging` nicht deklariert — dann verwirft
|
|
||||||
// seine SDK-Seite die Notification, bevor das Modell sie je sieht. Genau das
|
|
||||||
// ist der Verdacht, nachdem codex und kimi den Push nicht wahrgenommen haben.
|
|
||||||
// Wir schreiben es weg, statt zu spekulieren.
|
|
||||||
try {
|
|
||||||
const caps = server.server.getClientCapabilities();
|
|
||||||
const dir = join(homedir(), '.cache', 'agenthub');
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
writeFileSync(
|
|
||||||
join(dir, `client-capabilities-${name}.json`),
|
|
||||||
JSON.stringify({ agent: name, at: new Date().toISOString(), capabilities: caps ?? null }, null, 2),
|
|
||||||
);
|
|
||||||
} catch { /* Diagnose darf den Kanal nie blockieren */ }
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
// Aliase mitnehmen, damit ein umbenannter Agent seine Events weiter bekommt.
|
|
||||||
try {
|
|
||||||
const identity = await remoteClient.getAgentIdentity(serverUrl, agent);
|
|
||||||
for (const alias of identity.names) state.names.add(alias.toLowerCase());
|
|
||||||
} catch { /* Namensvergleich reicht */ }
|
|
||||||
|
|
||||||
let attempt = 0;
|
|
||||||
while (!stopped) {
|
|
||||||
try {
|
|
||||||
controller = new AbortController();
|
|
||||||
const res = await fetch(new URL('/events', serverUrl).toString(), {
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: {
|
|
||||||
Accept: 'text/event-stream',
|
|
||||||
...(lastEventId !== undefined ? { 'Last-Event-ID': String(lastEventId) } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!res.body) throw new Error('SSE ohne Body');
|
|
||||||
attempt = 0;
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = '';
|
|
||||||
while (!stopped) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
if (value) buffer += decoder.decode(value, { stream: true });
|
|
||||||
const { events, remaining } = parseSSEBuffer(buffer);
|
|
||||||
buffer = remaining;
|
|
||||||
for (const event of events) {
|
|
||||||
// Cursor fuer ALLE Events fortschreiben, nicht nur fuer relevante:
|
|
||||||
// Der Server replayt global. Bliebe der Cursor bei fremden Events
|
|
||||||
// stehen, wuerden sie nach jedem Reconnect erneut uebertragen.
|
|
||||||
if (event.seq !== undefined) lastEventId = event.seq;
|
|
||||||
if (!relevantFor(event, state.names)) continue;
|
|
||||||
try {
|
|
||||||
await server.server.sendLoggingMessage({
|
|
||||||
level: 'info',
|
|
||||||
logger: 'agenthub',
|
|
||||||
data: { agenthub: describe(event), event },
|
|
||||||
});
|
|
||||||
} catch { /* Client mag Notifications nicht — kein Grund abzubrechen */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* Hub weg, Neustart oder stop()-Abort — unten behandeln */ }
|
|
||||||
if (stopped) return;
|
|
||||||
await sleep(RECONNECT_DELAYS_MS[Math.min(attempt++, RECONNECT_DELAYS_MS.length - 1)]!);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
@ -18,32 +18,13 @@ import {
|
|||||||
} from '../core/services/taskService.js';
|
} from '../core/services/taskService.js';
|
||||||
import { createHandoff, getHandoff } from '../core/services/handoffService.js';
|
import { createHandoff, getHandoff } from '../core/services/handoffService.js';
|
||||||
import { appendTaskLog } from '../core/services/taskLogService.js';
|
import { appendTaskLog } from '../core/services/taskLogService.js';
|
||||||
import { resolvePending, hasPending } from '../core/services/checkinService.js';
|
|
||||||
import { bindPushChannel } from './pushChannel.js';
|
|
||||||
import { createAsk, listAsks, answerAsk, escalateAsk } from '../core/services/askService.js';
|
import { createAsk, listAsks, answerAsk, escalateAsk } from '../core/services/askService.js';
|
||||||
import type { Ask } from '../core/schema.js';
|
import type { Ask } from '../core/schema.js';
|
||||||
import { createMessage, listInbox } from '../core/services/messageService.js';
|
import { createMessage, listInbox, markMessageRead } from '../core/services/messageService.js';
|
||||||
import { addMemory, searchMemory } from '../core/services/memoryService.js';
|
import { addMemory, searchMemory } from '../core/services/memoryService.js';
|
||||||
import { createDecision } from '../core/services/decisionService.js';
|
import { createDecision } from '../core/services/decisionService.js';
|
||||||
import { getStatus } from '../core/services/statusService.js';
|
import { getStatus } from '../core/services/statusService.js';
|
||||||
import { discoverServer as discoverHubServer, resolveReachableServerUrl } from '../discovery.js';
|
import { discoverServer as discoverHubServer } from '../discovery.js';
|
||||||
import { VERSION } from '../version.js';
|
|
||||||
import { setPreferredAgent } from '../core/services/roleService.js';
|
|
||||||
|
|
||||||
export const DEFAULT_WORK_TIMEOUT_SEC = 50;
|
|
||||||
export const DEFAULT_TASK_LIST_LIMIT = 50;
|
|
||||||
|
|
||||||
export function boundedTaskList<T extends { updatedAt?: string; createdAt?: string }>(
|
|
||||||
tasks: T[],
|
|
||||||
limit: number = DEFAULT_TASK_LIST_LIMIT,
|
|
||||||
): { tasks: T[]; total: number; returned: number; omitted: number } {
|
|
||||||
const safeLimit = Math.max(1, Math.min(200, Math.floor(limit)));
|
|
||||||
const sorted = [...tasks].sort((a, b) =>
|
|
||||||
String(b.updatedAt ?? b.createdAt ?? '').localeCompare(String(a.updatedAt ?? a.createdAt ?? '')),
|
|
||||||
);
|
|
||||||
const selected = sorted.slice(0, safeLimit);
|
|
||||||
return { tasks: selected, total: tasks.length, returned: selected.length, omitted: tasks.length - selected.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AgentHub MCP server (TSK-0030).
|
* AgentHub MCP server (TSK-0030).
|
||||||
@ -57,10 +38,7 @@ export function boundedTaskList<T extends { updatedAt?: string; createdAt?: stri
|
|||||||
*
|
*
|
||||||
* Transport: stdio (each agent's CLI spawns `agenthub mcp` as a subprocess).
|
* Transport: stdio (each agent's CLI spawns `agenthub mcp` as a subprocess).
|
||||||
*/
|
*/
|
||||||
export async function resolveMcpContext(
|
function resolveContext(cwd: string): { root: string; serverUrl?: string } {
|
||||||
cwd: string,
|
|
||||||
options: Parameters<typeof resolveReachableServerUrl>[1] = {},
|
|
||||||
): Promise<{ root: string; serverUrl?: string }> {
|
|
||||||
const root = findProjectRoot(cwd) ?? cwd;
|
const root = findProjectRoot(cwd) ?? cwd;
|
||||||
let serverUrl = process.env.AGENTHUB_SERVER || undefined;
|
let serverUrl = process.env.AGENTHUB_SERVER || undefined;
|
||||||
if (!serverUrl) {
|
if (!serverUrl) {
|
||||||
@ -70,9 +48,7 @@ export async function resolveMcpContext(
|
|||||||
// no project config — local/none
|
// no project config — local/none
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!serverUrl) return { root };
|
return { root, serverUrl };
|
||||||
const reachable = await resolveReachableServerUrl(serverUrl, options);
|
|
||||||
return reachable ? { root, serverUrl: reachable } : { root };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function asText(value: unknown) {
|
function asText(value: unknown) {
|
||||||
@ -93,14 +69,8 @@ async function resolveReconnectUrl(currentUrl: string): Promise<string> {
|
|||||||
return discovered || currentUrl;
|
return discovered || currentUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Block on the SSE stream until findClaim() returns a task, or timeout. */
|
||||||
* Block on the SSE stream until findClaim() returns a task, or timeout.
|
function waitForTask<T>(
|
||||||
* Exported for the auto-wake regression tests (TSK-0230): the tests drive it
|
|
||||||
* with a findClaim that mimics the agenthub_work finder, proving the wait
|
|
||||||
* wakes on task_assign / incoming message via SSE AND via the polling
|
|
||||||
* fallback when SSE is down.
|
|
||||||
*/
|
|
||||||
export function waitForTask<T>(
|
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
findClaim: (serverUrl: string) => Promise<T | null>,
|
findClaim: (serverUrl: string) => Promise<T | null>,
|
||||||
timeoutSec: number,
|
timeoutSec: number,
|
||||||
@ -112,47 +82,21 @@ export function waitForTask<T>(
|
|||||||
const deadline = Date.now() + Math.max(1, timeoutSec) * 1000;
|
const deadline = Date.now() + Math.max(1, timeoutSec) * 1000;
|
||||||
const backoffs = [2000, 5000, 10000];
|
const backoffs = [2000, 5000, 10000];
|
||||||
let reconnectAttempt = 0;
|
let reconnectAttempt = 0;
|
||||||
let lastEventId: number | undefined;
|
|
||||||
let poll: NodeJS.Timeout | undefined;
|
|
||||||
const finish = (v: T | null) => {
|
const finish = (v: T | null) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
if (poll) clearInterval(poll);
|
|
||||||
try { controller?.abort(); } catch { /* already */ }
|
try { controller?.abort(); } catch { /* already */ }
|
||||||
resolve(v);
|
resolve(v);
|
||||||
};
|
};
|
||||||
const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000);
|
const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000);
|
||||||
|
|
||||||
// Polling fallback: a dropped SSE frame on an otherwise-open stream must
|
|
||||||
// never mean an infinite sleep — re-run findClaim every few seconds, so a
|
|
||||||
// lost event costs at most one poll interval (~4s).
|
|
||||||
let polling = false;
|
|
||||||
poll = setInterval(() => {
|
|
||||||
if (settled || polling) return;
|
|
||||||
polling = true;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const hit = await findClaim(currentUrl);
|
|
||||||
if (hit) { clearTimeout(timer); finish(hit); }
|
|
||||||
} catch {
|
|
||||||
/* best-effort: the SSE path and the next tick remain */
|
|
||||||
} finally {
|
|
||||||
polling = false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, 4000);
|
|
||||||
poll.unref?.();
|
|
||||||
|
|
||||||
const waitLoop = async () => {
|
const waitLoop = async () => {
|
||||||
while (!settled && remainingMs(deadline) > 0) {
|
while (!settled && remainingMs(deadline) > 0) {
|
||||||
controller = new AbortController();
|
controller = new AbortController();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(new URL('/events', currentUrl).toString(), {
|
const res = await fetch(new URL('/events', currentUrl).toString(), {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: { Accept: 'text/event-stream' },
|
||||||
Accept: 'text/event-stream',
|
|
||||||
...(lastEventId !== undefined ? { 'Last-Event-ID': String(lastEventId) } : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!res.body) throw new Error('SSE response has no body');
|
if (!res.body) throw new Error('SSE response has no body');
|
||||||
// Close the gap: a task may have arrived between the initial check and now.
|
// Close the gap: a task may have arrived between the initial check and now.
|
||||||
@ -169,9 +113,6 @@ export function waitForTask<T>(
|
|||||||
if (value) buffer += decoder.decode(value, { stream: true });
|
if (value) buffer += decoder.decode(value, { stream: true });
|
||||||
const { events, remaining } = parseSSEBuffer(buffer);
|
const { events, remaining } = parseSSEBuffer(buffer);
|
||||||
buffer = remaining;
|
buffer = remaining;
|
||||||
for (const event of events) {
|
|
||||||
if (event.seq !== undefined) lastEventId = event.seq;
|
|
||||||
}
|
|
||||||
// Wake on a new task, a message, or an ask (decision routed to the
|
// Wake on a new task, a message, or an ask (decision routed to the
|
||||||
// architect / answered back to the asker).
|
// architect / answered back to the asker).
|
||||||
if (events.some((e) => e.type === 'task' || e.type === 'message' || e.type === 'ask')) {
|
if (events.some((e) => e.type === 'task' || e.type === 'message' || e.type === 'ask')) {
|
||||||
@ -199,60 +140,37 @@ export function waitForTask<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startMcpServer(cwd: string, options: { agent?: string } = {}): Promise<void> {
|
export async function startMcpServer(cwd: string): Promise<void> {
|
||||||
const { root, serverUrl } = await resolveMcpContext(cwd);
|
const { root, serverUrl } = resolveContext(cwd);
|
||||||
const remote = !!serverUrl;
|
const remote = !!serverUrl;
|
||||||
// `logging` MUSS deklariert werden: sendLoggingMessage prueft
|
const server = new McpServer({ name: 'agenthub', version: '0.8.0' });
|
||||||
// _capabilities.logging und verwirft die Notification sonst kommentarlos.
|
|
||||||
// Ohne diese Zeile gaebe es keinen Push — und man saehe nicht, warum.
|
|
||||||
const server = new McpServer(
|
|
||||||
{ name: 'agenthub', version: VERSION },
|
|
||||||
{ capabilities: { logging: {} } },
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Bridge an den Agenten binden, sobald er sich zu erkennen gibt. */
|
|
||||||
const bindPush = (agent?: string) => {
|
|
||||||
if (remote && agent) bindPushChannel(server, serverUrl!, agent);
|
|
||||||
};
|
|
||||||
// Beim Start binden, wenn der Name bekannt ist (`agenthub mcp --agent codex`).
|
|
||||||
// Ohne das beginnt der Push erst mit dem ersten Tool-Aufruf — und genau in
|
|
||||||
// dem Fenster davor ist der Agent wieder taub, also fuer den Zustand, den
|
|
||||||
// wir beheben wollen.
|
|
||||||
bindPush(options.agent);
|
|
||||||
|
|
||||||
server.tool('agenthub_hello', 'Announce yourself to the hub (presence). Run once when you start.',
|
server.tool('agenthub_hello', 'Announce yourself to the hub (presence). Run once when you start.',
|
||||||
{ agent: z.string(), role: z.string().optional() },
|
{ agent: z.string(), role: z.string().optional() },
|
||||||
async ({ agent, role }) => {
|
async ({ agent, role }) => {
|
||||||
if (remote) await remoteClient.announce(serverUrl!, agent, role ?? 'implementer');
|
if (remote) await remoteClient.announce(serverUrl!, agent, role ?? 'implementer');
|
||||||
bindPush(agent);
|
|
||||||
return asText(`AgentHub: ${agent} joined (${role ?? 'implementer'})`);
|
return asText(`AgentHub: ${agent} joined (${role ?? 'implementer'})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
server.tool('agenthub_work',
|
server.tool('agenthub_work',
|
||||||
'Block until there is work for you, then return it. The default 50s timeout stays below known MCP client limits; SSE still wakes immediately, so this only causes more empty wake-ups and does not add delivery latency. IMPLEMENTER: waits for a task addressed to you (newly delegated OR reopened), claims it, returns it with its handoff (loop: work -> implement -> agenthub_task_review -> work). ARCHITECT/REVIEWER: waits for any task submitted to review and returns the pending set to approve (agenthub_task_done) or reject (agenthub_task_reopen) — so review submissions reach you in realtime.',
|
'Block until there is work for you, then return it. IMPLEMENTER: waits for a task addressed to you (newly delegated OR reopened), claims it, returns it with its handoff (loop: work -> implement -> agenthub_task_review -> work). ARCHITECT/REVIEWER: waits for any task submitted to review and returns the pending set to approve (agenthub_task_done) or reject (agenthub_task_reopen) — so review submissions reach you in realtime.',
|
||||||
{ agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional(), unattended: z.boolean().optional() },
|
{ agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional(), unattended: z.boolean().optional() },
|
||||||
async ({ agent, role, timeoutSec, unattended }) => {
|
async ({ agent, role, timeoutSec, unattended }) => {
|
||||||
bindPush(agent);
|
|
||||||
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
|
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
|
||||||
if (remote) await remoteClient.setLoop(serverUrl!, agent, true);
|
|
||||||
const leave = async (reason: string) => {
|
|
||||||
if (remote) {
|
|
||||||
try { await remoteClient.setLoop(ctx.serverUrl!, agent, false, reason); } catch { /* best-effort */ }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const reviewer = isReviewerRole(ctx.role);
|
const reviewer = isReviewerRole(ctx.role);
|
||||||
const useServerUrl = (nextServerUrl?: string) => {
|
const useServerUrl = (nextServerUrl?: string) => {
|
||||||
if (nextServerUrl) ctx.serverUrl = nextServerUrl;
|
if (nextServerUrl) ctx.serverUrl = nextServerUrl;
|
||||||
return ctx.serverUrl!;
|
return ctx.serverUrl!;
|
||||||
};
|
};
|
||||||
// Fetch the agent's unread messages so the work loop surfaces them.
|
// Fetch + mark-read the agent's unread messages, so the work loop surfaces
|
||||||
// Surfacing flips them `unread` → `delivered` (the listInbox read
|
// them once and doesn't spin on the same message.
|
||||||
// receipt), but nothing auto-marks them `read`: the message stays
|
|
||||||
// visible in the inbox until an explicit ack/read, and the loop wakes
|
|
||||||
// only on `unread`, so a delivered message never re-wakes it (no spin).
|
|
||||||
const drainInbox = async (nextServerUrl?: string) => {
|
const drainInbox = async (nextServerUrl?: string) => {
|
||||||
const activeUrl = nextServerUrl ? useServerUrl(nextServerUrl) : ctx.serverUrl;
|
const activeUrl = nextServerUrl ? useServerUrl(nextServerUrl) : ctx.serverUrl;
|
||||||
return remote ? await remoteClient.getInbox(activeUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
|
const msgs = remote ? await remoteClient.getInbox(activeUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
|
||||||
|
for (const m of msgs) {
|
||||||
|
try { if (remote) await remoteClient.markMessageRead(activeUrl!, m.id); else markMessageRead(root, m.id); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
return msgs;
|
||||||
};
|
};
|
||||||
const findWork = async (nextServerUrl?: string) => {
|
const findWork = async (nextServerUrl?: string) => {
|
||||||
if (nextServerUrl) useServerUrl(nextServerUrl);
|
if (nextServerUrl) useServerUrl(nextServerUrl);
|
||||||
@ -276,7 +194,6 @@ export async function startMcpServer(cwd: string, options: { agent?: string } =
|
|||||||
// tasks addressed to you). Returns the pending review set — never claims.
|
// tasks addressed to you). Returns the pending review set — never claims.
|
||||||
const listPendingAsks = async (): Promise<Ask[]> =>
|
const listPendingAsks = async (): Promise<Ask[]> =>
|
||||||
remote ? await remoteClient.listAsks(ctx.serverUrl!, { status: 'pending' }) : listAsks(root, { status: 'pending' });
|
remote ? await remoteClient.listAsks(ctx.serverUrl!, { status: 'pending' }) : listAsks(root, { status: 'pending' });
|
||||||
let architectEventSeq: number | undefined;
|
|
||||||
const findReview = async (nextServerUrl?: string) => {
|
const findReview = async (nextServerUrl?: string) => {
|
||||||
if (nextServerUrl) useServerUrl(nextServerUrl);
|
if (nextServerUrl) useServerUrl(nextServerUrl);
|
||||||
const reviews = await listReviewTasks(ctx);
|
const reviews = await listReviewTasks(ctx);
|
||||||
@ -291,27 +208,6 @@ export async function startMcpServer(cwd: string, options: { agent?: string } =
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (messages.length) return { reviews: [], asks: [], messages, note: 'Nothing in review, but you have messages — reply with agenthub_message.' };
|
if (messages.length) return { reviews: [], asks: [], messages, note: 'Nothing in review, but you have messages — reply with agenthub_message.' };
|
||||||
// The active architect also needs visibility into every task lifecycle
|
|
||||||
// transition, not just tasks that happen to enter review. Seed the
|
|
||||||
// durable cursor without replaying stale history, then return subsequent
|
|
||||||
// task events immediately (SSE) or within the polling fallback.
|
|
||||||
if (ctx.role.toLowerCase() === 'architect' && remote) {
|
|
||||||
const pulse = await remoteClient.getArchitectPulse(ctx.serverUrl!, architectEventSeq);
|
|
||||||
const seeded = architectEventSeq === undefined;
|
|
||||||
architectEventSeq = pulse.nextSeq;
|
|
||||||
if (!seeded) {
|
|
||||||
const taskEvents = pulse.events.filter((event) => event.type === 'task');
|
|
||||||
if (taskEvents.length) {
|
|
||||||
return {
|
|
||||||
reviews: [],
|
|
||||||
asks: [],
|
|
||||||
messages: [],
|
|
||||||
events: taskEvents,
|
|
||||||
note: 'Task status changed. Inspect the affected task, then re-arm agenthub_work.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -334,76 +230,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) return asText({ ...immediate, loop: LOOP });
|
||||||
await leave('work delivered');
|
|
||||||
return asText({ ...immediate, loop: LOOP });
|
|
||||||
}
|
|
||||||
const emptyMsg = reviewer
|
const emptyMsg = reviewer
|
||||||
? `No task in review for ${agent}, and no hub server to wait on.`
|
? `No task in review for ${agent}, and no hub server to wait on.`
|
||||||
: `No open task addressed to ${agent}, and no hub server to wait on.`;
|
: `No open task addressed to ${agent}, and no hub server to wait on.`;
|
||||||
if (!remote) return asText(emptyMsg);
|
if (!remote) return asText(emptyMsg);
|
||||||
// Timeout-Aufloesung: explizites Argument > Roster-Einstellung des Agenten
|
const result = await waitForTask(serverUrl!, finder, timeoutSec ?? 300);
|
||||||
// > globaler Default. Der Roster-Wert existiert, weil die Grenze am
|
if (result) return asText({ ...result, loop: LOOP });
|
||||||
// MCP-CLIENT haengt: Kimi-Code bricht nach gut einer Minute ab, Codex
|
|
||||||
// vertraegt Minuten. Ein globaler Default zwingt alle auf das Minimum des
|
|
||||||
// schwaechsten Clients — der robustere Agent kehrt dann unnoetig oft leer
|
|
||||||
// zurueck, und jede Rueckkehr ist eine Gelegenheit, aus dem Loop zu fallen.
|
|
||||||
let rosterTimeout: number | undefined;
|
|
||||||
if (timeoutSec === undefined) {
|
|
||||||
try {
|
|
||||||
rosterTimeout = (await remoteClient.getAgentIdentity(serverUrl!, agent)).workTimeoutSec;
|
|
||||||
} catch { /* Roster nicht erreichbar — Default greift */ }
|
|
||||||
}
|
|
||||||
const effectiveTimeout = timeoutSec ?? rosterTimeout ?? DEFAULT_WORK_TIMEOUT_SEC;
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = await waitForTask(serverUrl!, finder, effectiveTimeout);
|
|
||||||
} catch (err) {
|
|
||||||
await leave(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
if (result) {
|
|
||||||
await leave('work delivered');
|
|
||||||
return asText({ ...result, loop: LOOP });
|
|
||||||
}
|
|
||||||
await leave('timeout');
|
|
||||||
const kind = reviewer ? 'review submission' : 'task or message';
|
const kind = reviewer ? 'review submission' : 'task or message';
|
||||||
return asText(`No ${kind} for ${agent} within ${effectiveTimeout}s. ${LOOP}`);
|
return asText(`No ${kind} for ${agent} within ${timeoutSec ?? 300}s. ${LOOP}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
server.tool('agenthub_task_list', 'List tasks newest-first, optionally filtered by status and/or role. Defaults to 50 and reports total/omitted counts.',
|
server.tool('agenthub_task_list', 'List tasks, optionally filtered by status and/or role.',
|
||||||
{ status: z.string().optional(), role: z.string().optional(), limit: z.number().int().positive().max(200).optional() },
|
{ status: z.string().optional(), role: z.string().optional() },
|
||||||
async ({ status, role, limit }) => {
|
async ({ status, role }) => asText(remote ? await remoteClient.listTasks(serverUrl!, { status, role }) : listTasks(root, { status, role })));
|
||||||
const tasks = remote ? await remoteClient.listTasks(serverUrl!, { status, role }) : listTasks(root, { status, role });
|
|
||||||
return asText(boundedTaskList(tasks, limit));
|
|
||||||
});
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'agenthub_role_set',
|
|
||||||
'Switch the preferred agent for a role. Use this to hand the architect role to another agent during maintenance; the prior architect falls back to its other preferred role or implementer.',
|
|
||||||
{ role: z.enum(['architect', 'implementer', 'reviewer', 'tester']), agent: z.string() },
|
|
||||||
async ({ role, agent }) => asText(
|
|
||||||
remote
|
|
||||||
? await remoteClient.setPreferredAgent(serverUrl!, role, agent)
|
|
||||||
: setPreferredAgent(root, role, agent),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
server.tool('agenthub_task_show', 'Show one task with its full body.',
|
server.tool('agenthub_task_show', 'Show one task with its full body.',
|
||||||
{ id: z.string() },
|
{ id: z.string() },
|
||||||
@ -427,48 +268,7 @@ export async function startMcpServer(cwd: string, options: { agent?: string } =
|
|||||||
|
|
||||||
server.tool('agenthub_task_review', 'Submit a finished task for architect review. Implementers use THIS, never agenthub_task_done.',
|
server.tool('agenthub_task_review', 'Submit a finished task for architect review. Implementers use THIS, never agenthub_task_done.',
|
||||||
{ id: z.string() },
|
{ id: z.string() },
|
||||||
async ({ id }) => {
|
async ({ id }) => asText(remote ? await remoteClient.reviewTask(serverUrl!, id) : reviewTask(root, id)));
|
||||||
const submitted = remote ? await remoteClient.reviewTask(serverUrl!, id) : reviewTask(root, id);
|
|
||||||
if (!remote) {
|
|
||||||
return asText({ submitted, note: 'Submitted. Relaunch agenthub_work to stay reachable.' });
|
|
||||||
}
|
|
||||||
const agent = submitted.claimedBy ?? submitted.assignedTo;
|
|
||||||
if (!agent) return asText({ submitted, note: 'Submitted, but no implementer identity is available for continued waiting.' });
|
|
||||||
const ctx: AgentContext = {
|
|
||||||
serverUrl,
|
|
||||||
projectCwd: root,
|
|
||||||
agent,
|
|
||||||
role: submitted.role ?? 'implementer',
|
|
||||||
};
|
|
||||||
await remoteClient.setLoop(serverUrl!, agent, true);
|
|
||||||
const finder = async (nextServerUrl?: string) => {
|
|
||||||
if (nextServerUrl) ctx.serverUrl = nextServerUrl;
|
|
||||||
const found = await findAddressedOpenTask(ctx);
|
|
||||||
if (found) {
|
|
||||||
await remoteClient.claimTask(ctx.serverUrl!, found.task.id, agent);
|
|
||||||
const detail = await remoteClient.getTask(ctx.serverUrl!, found.task.id);
|
|
||||||
return { claimed: found.task, body: detail.body };
|
|
||||||
}
|
|
||||||
const messages = await remoteClient.getInbox(ctx.serverUrl!, agent, true);
|
|
||||||
return messages.length ? { claimed: null, messages } : null;
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const next = await waitForTask(serverUrl!, finder, DEFAULT_WORK_TIMEOUT_SEC);
|
|
||||||
await remoteClient.setLoop(ctx.serverUrl!, agent, false, next ? 'next work delivered' : 'timeout');
|
|
||||||
return asText({
|
|
||||||
submitted,
|
|
||||||
next,
|
|
||||||
note: next
|
|
||||||
? 'Review submitted and the implementer stayed reachable; next work/message is included.'
|
|
||||||
: `Review submitted; no next work arrived within ${DEFAULT_WORK_TIMEOUT_SEC}s.`,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
try {
|
|
||||||
await remoteClient.setLoop(ctx.serverUrl!, agent, false, `error: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
} catch { /* best-effort */ }
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.tool('agenthub_task_reopen', 'Re-trigger a task after review (architect: send back to the implementer).',
|
server.tool('agenthub_task_reopen', 'Re-trigger a task after review (architect: send back to the implementer).',
|
||||||
{ id: z.string() },
|
{ id: z.string() },
|
||||||
@ -479,30 +279,9 @@ export async function startMcpServer(cwd: string, options: { agent?: string } =
|
|||||||
async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id)));
|
async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id)));
|
||||||
|
|
||||||
server.tool('agenthub_task_log',
|
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 after EVERY step (e.g. "wrote failing test", "green: 12 tests", "blocked on X"). '
|
'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").',
|
||||||
+ 'IMPORTANT: the response is your only inbox while you work — you receive no events during a task. '
|
|
||||||
+ 'If it contains a `pending` block, ACT ON IT IMMEDIATELY: `pending.interrupted` means the task was reopened, cancelled or taken from you — STOP building, do not submit, follow `pending.interrupted.action`. '
|
|
||||||
+ 'Unread messages and newly assigned tasks arrive here too.',
|
|
||||||
{ id: z.string(), text: z.string(), agent: z.string().optional(), level: z.string().optional() },
|
{ id: z.string(), text: z.string(), agent: z.string().optional(), level: z.string().optional() },
|
||||||
async ({ id, text, agent, level }) => {
|
async ({ id, text, agent, level }) => asText(remote ? await remoteClient.appendTaskLog(serverUrl!, id, { text, agent, level }) : appendTaskLog(root, id, { text, agent, level })));
|
||||||
if (remote) {
|
|
||||||
// Der Server hängt den pending-Block selbst an (siehe routes.ts).
|
|
||||||
return asText(await remoteClient.appendTaskLog(serverUrl!, id, { text, agent, level }));
|
|
||||||
}
|
|
||||||
const entry = appendTaskLog(root, id, { text, agent, level });
|
|
||||||
if (!agent) return asText(entry);
|
|
||||||
const pending = resolvePending(root, agent, id);
|
|
||||||
return asText(hasPending(pending) ? { ...entry, pending } : entry);
|
|
||||||
});
|
|
||||||
|
|
||||||
server.tool('agenthub_checkin',
|
|
||||||
'Check what is waiting for you WITHOUT writing a log line. Use it when a task runs long between log points: while you execute a task you receive no events, so this is how you notice a reopen, a cancellation or a new message. '
|
|
||||||
+ 'Pass the task you are currently working on to learn whether it is still yours.',
|
|
||||||
{ agent: z.string(), taskId: z.string().optional() },
|
|
||||||
async ({ agent, taskId }) =>
|
|
||||||
asText(remote
|
|
||||||
? await remoteClient.getPending(serverUrl!, agent, taskId)
|
|
||||||
: resolvePending(root, agent, taskId)));
|
|
||||||
|
|
||||||
server.tool('agenthub_memory_add', 'Record a result / finding / blocker as a memory entry.',
|
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() },
|
{ title: z.string(), category: z.enum(['architecture', 'product', 'technical', 'implementation', 'lesson']).optional(), content: z.string() },
|
||||||
|
|||||||
@ -1,362 +0,0 @@
|
|||||||
/**
|
|
||||||
* Agent health-check page, served at `GET /agent-health` (TSK-0230).
|
|
||||||
*
|
|
||||||
* One glance answers "which agents are reachable?" — and proves it: per agent
|
|
||||||
* a test message can be sent whose delivery status (sent → delivered → read)
|
|
||||||
* flips LIVE when the agent's work loop auto-wakes (HOF-0086 #8 — the button
|
|
||||||
* is the live proof that auto-wake works, no manual poke).
|
|
||||||
*
|
|
||||||
* Data sources (all reused, nothing reimplemented):
|
|
||||||
* - GET /health → the TSK-0226 traffic light (active/busy/idle/stale),
|
|
||||||
* lastSeen, busy-on/waiting task.
|
|
||||||
* - GET /messages → side-effect-free full list (listMessages) for the
|
|
||||||
* cross-messaging view + delivery tracking. NEVER the
|
|
||||||
* ?agent= inbox variant — listInbox flips unread→delivered
|
|
||||||
* as a read receipt and would fake delivery.
|
|
||||||
* - GET /events → SSE. The browser's EventSource sends Last-Event-ID on
|
|
||||||
* reconnect natively, so the durable event log (TSK-0225)
|
|
||||||
* replays what it missed. When SSE drops, the page keeps
|
|
||||||
* polling every 3s and SAYS so (transport badge).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { loadConfig } from '../core/config.js';
|
|
||||||
import { computeHealth, type AgentHealth } from '../core/services/presenceService.js';
|
|
||||||
import { listMessages, type InboxMessage } from '../core/services/messageService.js';
|
|
||||||
import { designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, newTaskModalJs } from './ui-shared.js';
|
|
||||||
|
|
||||||
/** JSON for safe embedding in a <script> tag (no </script breakout). */
|
|
||||||
function embedJson(value: unknown): string {
|
|
||||||
return JSON.stringify(value).replace(/</g, '\\u003c');
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HealthPageData {
|
|
||||||
health: ReturnType<typeof computeHealth>;
|
|
||||||
messages: InboxMessage[];
|
|
||||||
architect: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pageData(cwd: string, startedAtMs: number): HealthPageData {
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
return {
|
|
||||||
health: computeHealth(cwd, startedAtMs),
|
|
||||||
messages: listMessages(cwd).slice(-200),
|
|
||||||
architect: config.roles.architect?.preferredAgent ?? 'claude',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Initial SSR rows — the JS re-renders live afterwards (same card shape). */
|
|
||||||
function renderAgentCardsSSR(agents: AgentHealth[]): string {
|
|
||||||
return agents
|
|
||||||
.map(
|
|
||||||
(a) => `<article class="agent-card" data-agent="${escapeHtml(a.name)}">
|
|
||||||
<header class="ac-head">
|
|
||||||
<span class="ac-name">${escapeHtml(a.name)}</span>
|
|
||||||
<span class="ac-role">${escapeHtml(a.role)}</span>
|
|
||||||
<span class="light light-${a.state}" data-light>${a.state}</span>
|
|
||||||
</header>
|
|
||||||
<div class="ac-meta" data-meta>${a.inLoop
|
|
||||||
? `in loop since ${escapeHtml(a.loopSince ?? 'now')}`
|
|
||||||
: a.loopExitReason
|
|
||||||
? `out of loop · ${escapeHtml(a.loopExitReason)}`
|
|
||||||
: 'loop state unknown'}</div>
|
|
||||||
<div class="ac-test">
|
|
||||||
<input class="ac-input" type="text" value="health-check ping" maxlength="200" aria-label="Test message" />
|
|
||||||
<button class="ac-send" type="button">Send test</button>
|
|
||||||
</div>
|
|
||||||
<div class="ac-track" data-track></div>
|
|
||||||
<ul class="ac-msgs" data-msgs></ul>
|
|
||||||
</article>`,
|
|
||||||
)
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderAgentHealthHtml(cwd: string, startedAtMs: number): string {
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
const data = pageData(cwd, startedAtMs);
|
|
||||||
|
|
||||||
return `<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="color-scheme" content="dark" />
|
|
||||||
<title>AgentHub Agent Health</title>
|
|
||||||
<style>
|
|
||||||
${designTokensCss()}
|
|
||||||
${appHeaderCss()}
|
|
||||||
body { padding: 96px 20px 32px; }
|
|
||||||
main { max-width: 1120px; margin: 0 auto; display: grid; gap: 12px; }
|
|
||||||
|
|
||||||
.summary { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
|
|
||||||
.summary h1 { font-size: 18px; margin: 0; }
|
|
||||||
.chip { font: 11px/1 var(--font-mono); border: 1px solid var(--border); border-radius: 999px; padding: 5px 10px; color: var(--muted); white-space: nowrap; }
|
|
||||||
.chip b { color: var(--text); font-weight: 700; }
|
|
||||||
.spacer { flex: 1; }
|
|
||||||
|
|
||||||
/* Transport badge: SSE live vs polling fallback — visibility only. */
|
|
||||||
.transport { display: inline-flex; align-items: center; gap: 7px; font: 700 11px/1 var(--font-mono); border-radius: 999px; padding: 6px 12px; border: 1px solid var(--border); }
|
|
||||||
.transport .tdot { width: 8px; height: 8px; border-radius: 50%; }
|
|
||||||
.transport.sse { color: #adf2c7; border-color: rgba(34,197,94,.4); }
|
|
||||||
.transport.sse .tdot { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,.16); animation: tp 1.5s ease-in-out infinite; }
|
|
||||||
.transport.poll { color: #f5d08a; border-color: rgba(210,153,34,.45); }
|
|
||||||
.transport.poll .tdot { background: var(--status-review); box-shadow: 0 0 0 3px rgba(210,153,34,.16); }
|
|
||||||
@keyframes tp { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
|
|
||||||
|
|
||||||
.agents { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 12px; align-items: start; }
|
|
||||||
.agent-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; display: grid; gap: 9px; min-width: 0; }
|
|
||||||
.ac-head { display: flex; align-items: baseline; gap: 8px; }
|
|
||||||
.ac-name { font-weight: 700; font-size: 14px; }
|
|
||||||
.ac-role { font: 10.5px/1 var(--font-mono); color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
|
||||||
.ac-head .light { margin-left: auto; }
|
|
||||||
|
|
||||||
/* The TSK-0226 traffic light, same four states, same colors. */
|
|
||||||
.light { font: 700 10px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; border-radius: 999px; padding: 4px 9px; border: 1px solid transparent; }
|
|
||||||
.light-active { color: #adf2c7; border-color: rgba(34,197,94,.4); background: rgba(34,197,94,.08); }
|
|
||||||
.light-busy { color: #a8d0ff; border-color: rgba(88,166,255,.4); background: rgba(88,166,255,.08); }
|
|
||||||
.light-idle { color: var(--muted); border-color: var(--border); }
|
|
||||||
.light-stale { color: #ffb4b4; border-color: rgba(239,68,68,.45); background: rgba(239,68,68,.08); }
|
|
||||||
|
|
||||||
.ac-meta { font: 11px/1.5 var(--font-mono); color: var(--muted); min-height: 16px; }
|
|
||||||
/* TSK-0274: wartende Arbeit + Check-in-Alter (Taubheit) */
|
|
||||||
.ac-pending { color: var(--accent); font-weight: 600; }
|
|
||||||
.ac-deaf { color: var(--muted); }
|
|
||||||
.ac-deaf-warn { color: var(--status-review); font-weight: 600; }
|
|
||||||
.ac-meta a { color: var(--green); text-decoration: none; }
|
|
||||||
.ac-meta a:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.ac-test { display: flex; gap: 7px; }
|
|
||||||
.ac-input { flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--border); border-radius: 7px; color: var(--text); font: 12px/1 var(--font-mono); padding: 7px 9px; }
|
|
||||||
.ac-input:focus { outline: none; border-color: var(--accent); }
|
|
||||||
.ac-send { border: 1px solid var(--border); background: var(--raised); color: var(--text); font: 600 11.5px/1 var(--font-sans); border-radius: 7px; padding: 0 12px; cursor: pointer; white-space: nowrap; }
|
|
||||||
.ac-send:hover { border-color: var(--accent); }
|
|
||||||
.ac-send[disabled] { opacity: .55; cursor: default; }
|
|
||||||
|
|
||||||
.ac-track { font: 11px/1.4 var(--font-mono); min-height: 15px; }
|
|
||||||
.ac-track .t-sent { color: var(--muted); }
|
|
||||||
.ac-track .t-delivered { color: #a8d0ff; }
|
|
||||||
.ac-track .t-read { color: #adf2c7; }
|
|
||||||
.ac-track .t-timeout { color: #ffb4b4; }
|
|
||||||
|
|
||||||
.ac-msgs { list-style: none; margin: 0; padding: 0; display: grid; gap: 5px; border-top: 1px solid var(--border); padding-top: 8px; }
|
|
||||||
.ac-msgs:empty { display: none; }
|
|
||||||
.ac-msgs li { display: grid; grid-template-columns: auto minmax(0,1fr) auto; gap: 7px; align-items: baseline; font: 11px/1.45 var(--font-mono); color: var(--muted); }
|
|
||||||
.ac-msgs .dir { flex: 0 0 auto; }
|
|
||||||
.ac-msgs .dir.out { color: #a8d0ff; }
|
|
||||||
.ac-msgs .dir.in { color: var(--status-review); }
|
|
||||||
.ac-msgs .txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
|
||||||
.ac-msgs .mst { font-size: 9.5px; text-transform: uppercase; letter-spacing: .04em; }
|
|
||||||
.ac-msgs .mst.unread { color: var(--muted); }
|
|
||||||
.ac-msgs .mst.delivered { color: #a8d0ff; }
|
|
||||||
.ac-msgs .mst.read, .ac-msgs .mst.acked { color: #adf2c7; }
|
|
||||||
.ac-msgs-head { font: 700 9.5px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
${appHeader(config.projectName, 'health')}
|
|
||||||
<main>
|
|
||||||
<section class="summary">
|
|
||||||
<h1>Agent Health</h1>
|
|
||||||
<span class="chip">hub <b>v${escapeHtml(data.health.version)}</b></span>
|
|
||||||
<span class="chip">uptime <b data-uptime>${data.health.uptimeSec}s</b></span>
|
|
||||||
<span class="chip" data-counts></span>
|
|
||||||
<span class="spacer"></span>
|
|
||||||
<span class="transport poll" id="transport" title="Page transport: SSE (durable replay via Last-Event-ID) or 3s polling fallback">
|
|
||||||
<span class="tdot"></span><span id="transportLabel">polling fallback</span>
|
|
||||||
</span>
|
|
||||||
<span class="chip" id="seqChip" title="Last durable event seq seen (TSK-0225)">seq <b>—</b></span>
|
|
||||||
</section>
|
|
||||||
<section class="agents" id="agents">
|
|
||||||
${renderAgentCardsSSR(data.health.agents)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
window.__HEALTH_DATA__ = ${embedJson(data)};
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
var data = window.__HEALTH_DATA__ || { health: { agents: [], counts: {}, uptimeSec: 0 }, messages: [], architect: 'claude' };
|
|
||||||
var ARCHITECT = data.architect;
|
|
||||||
var MSG_COUNT = 5; // last N cross-messages per agent
|
|
||||||
var TRACK_TIMEOUT_MS = 90000; // test-message delivery timeout
|
|
||||||
var track = {}; // agent -> { id, sentAt, timedOut }
|
|
||||||
var lastSeq = null, eventCount = 0;
|
|
||||||
|
|
||||||
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
||||||
function agoSec(iso) { var t = Date.parse(iso); if (isNaN(t)) return null; return Math.max(0, Math.floor((Date.now() - t) / 1000)); }
|
|
||||||
function compact(s) { if (s == null) return '—'; 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 < 48) return h + 'h'; return Math.floor(h / 24) + 'd'; }
|
|
||||||
function snippet(text, max) { var c = String(text || '').replace(/\\s+/g, ' ').trim(); return c.length <= max ? c : c.slice(0, max - 1) + '…'; }
|
|
||||||
|
|
||||||
function setTransport(mode) {
|
|
||||||
var el = document.getElementById('transport');
|
|
||||||
var label = document.getElementById('transportLabel');
|
|
||||||
if (!el || !label) return;
|
|
||||||
el.classList.toggle('sse', mode === 'sse');
|
|
||||||
el.classList.toggle('poll', mode !== 'sse');
|
|
||||||
label.textContent = mode === 'sse' ? 'SSE live' : 'polling fallback';
|
|
||||||
}
|
|
||||||
function setSeq() {
|
|
||||||
var chip = document.getElementById('seqChip');
|
|
||||||
if (chip) chip.innerHTML = 'seq <b>' + (lastSeq == null ? '—' : esc(String(lastSeq))) + '</b> · ' + eventCount + ' events';
|
|
||||||
}
|
|
||||||
|
|
||||||
function agentMessages(agent) {
|
|
||||||
return data.messages
|
|
||||||
.filter(function(m) { return m.from === agent || m.to === agent; })
|
|
||||||
.slice(-MSG_COUNT)
|
|
||||||
.reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
function trackStatus(agent) {
|
|
||||||
var t = track[agent];
|
|
||||||
if (!t) return '';
|
|
||||||
var msg = null;
|
|
||||||
for (var i = data.messages.length - 1; i >= 0; i--) {
|
|
||||||
if (data.messages[i].id === t.id) { msg = data.messages[i]; break; }
|
|
||||||
}
|
|
||||||
var st = msg ? (msg.status || 'unread') : 'unread';
|
|
||||||
if (st === 'unread' && Date.now() - t.sentAt > TRACK_TIMEOUT_MS) {
|
|
||||||
t.timedOut = true;
|
|
||||||
return '<span class="t-timeout">✕ timeout — no delivery within ' + Math.round(TRACK_TIMEOUT_MS / 1000) + 's (agent not reachable)</span>';
|
|
||||||
}
|
|
||||||
var elapsed = compact(Math.floor((Date.now() - t.sentAt) / 1000));
|
|
||||||
if (st === 'unread') return '<span class="t-sent">● sent ' + esc(t.id) + ' — waiting for delivery… (' + elapsed + ')</span>';
|
|
||||||
if (st === 'delivered') return '<span class="t-delivered">● delivered ' + esc(t.id) + ' after ' + elapsed + ' — agent auto-woke, waiting for ack…</span>';
|
|
||||||
return '<span class="t-read">● ' + esc(st) + ' ' + esc(t.id) + ' after ' + elapsed + ' — round trip complete</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
var h = data.health;
|
|
||||||
var counts = h.counts || {};
|
|
||||||
var countsEl = document.querySelector('[data-counts]');
|
|
||||||
if (countsEl) countsEl.innerHTML = 'tasks <b>' + (counts.tasks || 0) + '</b> · open <b>' + (counts.open || 0) + '</b> · in progress <b>' + (counts.inProgress || 0) + '</b> · review <b>' + (counts.review || 0) + '</b> · unread <b>' + (counts.unreadMessages || 0) + '</b>';
|
|
||||||
var upEl = document.querySelector('[data-uptime]');
|
|
||||||
if (upEl) upEl.textContent = compact(h.uptimeSec);
|
|
||||||
|
|
||||||
var wrap = document.getElementById('agents');
|
|
||||||
if (!wrap) return;
|
|
||||||
// Ensure a card exists per agent (roster can grow while the page is open).
|
|
||||||
(h.agents || []).forEach(function(a) {
|
|
||||||
if (!wrap.querySelector('.agent-card[data-agent="' + esc(a.name) + '"]')) {
|
|
||||||
var el = document.createElement('article');
|
|
||||||
el.className = 'agent-card';
|
|
||||||
el.setAttribute('data-agent', a.name);
|
|
||||||
el.innerHTML = '<header class="ac-head"><span class="ac-name"></span><span class="ac-role"></span><span class="light" data-light></span></header>' +
|
|
||||||
'<div class="ac-meta" data-meta></div>' +
|
|
||||||
'<div class="ac-test"><input class="ac-input" type="text" value="health-check ping" maxlength="200" aria-label="Test message" />' +
|
|
||||||
'<button class="ac-send" type="button">Send test</button></div>' +
|
|
||||||
'<div class="ac-track" data-track></div><ul class="ac-msgs" data-msgs></ul>';
|
|
||||||
el.querySelector('.ac-name').textContent = a.name;
|
|
||||||
el.querySelector('.ac-role').textContent = a.role;
|
|
||||||
wrap.appendChild(el);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
(h.agents || []).forEach(function(a) {
|
|
||||||
var card = wrap.querySelector('.agent-card[data-agent="' + esc(a.name) + '"]');
|
|
||||||
if (!card) return;
|
|
||||||
var light = card.querySelector('[data-light]');
|
|
||||||
light.className = 'light light-' + a.state;
|
|
||||||
light.textContent = a.state;
|
|
||||||
var seen = a.lastSeenAgoSec != null ? 'last seen ' + compact(a.lastSeenAgoSec) + ' ago' : 'never seen';
|
|
||||||
var task = a.taskId ? ' · <a href="/tasks/' + esc(a.taskId) + '">' + esc(a.taskId) + '</a>' : '';
|
|
||||||
var loop = a.inLoop
|
|
||||||
? ' · in loop for ' + compact(a.loopSinceAgoSec || 0)
|
|
||||||
: a.loopExitReason
|
|
||||||
? ' · out of loop ' + compact(a.loopExitAgoSec || 0) + ' ago (' + esc(a.loopExitReason) + ')'
|
|
||||||
: ' · loop unknown';
|
|
||||||
// TSK-0274: Taubheit sichtbar machen. Ein arbeitender Agent empfaengt
|
|
||||||
// nichts — entscheidend ist, wie lange er nicht eingecheckt hat und
|
|
||||||
// wie viel derweil auf ihn wartet.
|
|
||||||
var pending = a.pendingCount > 0
|
|
||||||
? ' · <b class="ac-pending">' + a.pendingCount + ' Task(s) warten</b>'
|
|
||||||
: '';
|
|
||||||
if (a.unreadCount > 0) pending += ' · ' + a.unreadCount + ' ungelesen';
|
|
||||||
var deaf = a.deafForSec != null
|
|
||||||
? ' · <span class="' + (a.deafForSec > 300 ? 'ac-deaf-warn' : 'ac-deaf') + '">kein Check-in seit ' + compact(a.deafForSec) + '</span>'
|
|
||||||
: '';
|
|
||||||
card.querySelector('[data-meta]').innerHTML = esc(seen) + task + loop + pending + deaf;
|
|
||||||
var tr = card.querySelector('[data-track]');
|
|
||||||
var newTrack = trackStatus(a.name);
|
|
||||||
if (tr.innerHTML !== newTrack) tr.innerHTML = newTrack;
|
|
||||||
var msgs = agentMessages(a.name);
|
|
||||||
var ul = card.querySelector('[data-msgs]');
|
|
||||||
var html = msgs.length ? '<li class="ac-msgs-head"><span>cross-messaging (last ' + msgs.length + ')</span><span></span><span></span></li>' : '';
|
|
||||||
html += msgs.map(function(m) {
|
|
||||||
var out = m.from === a.name;
|
|
||||||
var other = out ? m.to : m.from;
|
|
||||||
var when = agoSec(m.createdAt || m.updatedAt);
|
|
||||||
return '<li><span class="dir ' + (out ? 'out' : 'in') + '">' + (out ? '→' : '←') + ' ' + esc(other) + '</span>' +
|
|
||||||
'<span class="txt" title="' + esc(m.text) + '">' + esc(snippet(m.text, 90)) + '</span>' +
|
|
||||||
'<span class="mst ' + esc(m.status || 'unread') + '">' + esc(m.status || 'unread') + ' · ' + compact(when) + '</span></li>';
|
|
||||||
}).join('');
|
|
||||||
if (ul.innerHTML !== html) ul.innerHTML = html;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
try {
|
|
||||||
var hr = await fetch('/health', { headers: { accept: 'application/json' } });
|
|
||||||
if (hr.ok) data.health = await hr.json();
|
|
||||||
// Full list — side-effect-free. The ?agent= inbox variant flips
|
|
||||||
// unread→delivered as a read receipt and would fake delivery here.
|
|
||||||
var mr = await fetch('/messages', { headers: { accept: 'application/json' } });
|
|
||||||
if (mr.ok) data.messages = (await mr.json()).slice(-200);
|
|
||||||
render();
|
|
||||||
} catch (e) {
|
|
||||||
setTransport('poll');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test-message send → live delivery tracking (the auto-wake live proof).
|
|
||||||
document.addEventListener('click', async function(e) {
|
|
||||||
var btn = e.target.closest && e.target.closest('.ac-send');
|
|
||||||
if (!btn || btn.disabled) return;
|
|
||||||
var card = btn.closest('.agent-card');
|
|
||||||
var agent = card.getAttribute('data-agent');
|
|
||||||
var input = card.querySelector('.ac-input');
|
|
||||||
var text = (input.value || 'health-check ping').trim() || 'health-check ping';
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
var res = await fetch('/messages', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
|
|
||||||
body: JSON.stringify({ from: 'health-check', to: agent, text: text + ' · ' + new Date().toISOString() }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('POST /messages failed: ' + res.status);
|
|
||||||
var msg = await res.json();
|
|
||||||
track[agent] = { id: msg.id, sentAt: Date.now(), timedOut: false };
|
|
||||||
await refresh();
|
|
||||||
} catch (err) {
|
|
||||||
var tr = card.querySelector('[data-track]');
|
|
||||||
if (tr) tr.innerHTML = '<span class="t-timeout">✕ send failed: ' + esc(err.message || String(err)) + '</span>';
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transport: SSE primary (browser EventSource resends Last-Event-ID on
|
|
||||||
// reconnect → durable replay from TSK-0225), 3s polling as the fallback.
|
|
||||||
if ('EventSource' in window) {
|
|
||||||
var es = new EventSource('/events');
|
|
||||||
es.onopen = function() { setTransport('sse'); };
|
|
||||||
es.onmessage = function(ev) {
|
|
||||||
eventCount++;
|
|
||||||
if (ev.lastEventId) lastSeq = ev.lastEventId;
|
|
||||||
setSeq();
|
|
||||||
refresh();
|
|
||||||
};
|
|
||||||
es.onerror = function() { setTransport('poll'); };
|
|
||||||
window.addEventListener('pagehide', function() { es.close(); });
|
|
||||||
} else {
|
|
||||||
setTransport('poll');
|
|
||||||
}
|
|
||||||
setTransport('poll');
|
|
||||||
setSeq();
|
|
||||||
render();
|
|
||||||
setInterval(refresh, 3000);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
${taskModalHtml()}
|
|
||||||
${newTaskModalJs()}
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
@ -58,14 +58,6 @@ export function modalsHtml(): string {
|
|||||||
<option value="critical">critical</option>
|
<option value="critical">critical</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="modal-field modal-field-inline">
|
|
||||||
<span class="modal-label">Already completed outside AgentHub</span>
|
|
||||||
<input id="tmExternal" type="checkbox" />
|
|
||||||
</label>
|
|
||||||
<label class="modal-field" id="tmExternalByWrap" hidden>
|
|
||||||
<span class="modal-label">Completed by</span>
|
|
||||||
<input id="tmExternalBy" type="text" placeholder="agent name" />
|
|
||||||
</label>
|
|
||||||
<p class="modal-note">Created unassigned — the architect picks it up and delegates it.</p>
|
<p class="modal-note">Created unassigned — the architect picks it up and delegates it.</p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="modal-cancel" id="tmCancel">Cancel</button>
|
<button type="button" class="modal-cancel" id="tmCancel">Cancel</button>
|
||||||
@ -235,15 +227,17 @@ export function columnsJs(opts: ColumnsJsOpts): string {
|
|||||||
var status = byStatus(t.status);
|
var status = byStatus(t.status);
|
||||||
var isOpen = openConsoles[t.id] ? true : false;
|
var isOpen = openConsoles[t.id] ? true : false;
|
||||||
var live = status === 'in_progress';
|
var live = status === 'in_progress';
|
||||||
// Console toggle lives in the card; the console itself opens as a
|
// Console lives inside the card while a task is worked (in_progress = live)
|
||||||
// dropdown OVERLAY on <body> (see openConsole) — it spans the full
|
// and stays available in review so you can see what the agent did.
|
||||||
// column width instead of being squeezed into the card grid cell.
|
|
||||||
var console = (live || status === 'review')
|
var console = (live || status === 'review')
|
||||||
? '<div class="card-console-wrap">' +
|
? '<div class="card-console-wrap">' +
|
||||||
'<button class="card-console-toggle" type="button" data-console-toggle="' + esc(t.id) + '" aria-expanded="' + (isOpen ? 'true' : 'false') + '">' +
|
'<button class="card-console-toggle" type="button" data-console-toggle="' + esc(t.id) + '" aria-expanded="' + (isOpen ? 'true' : 'false') + '">' +
|
||||||
(live ? '<span class="cc-dot" aria-hidden="true"></span>' : '') + (live ? 'live console' : 'agent console') +
|
(live ? '<span class="cc-dot" aria-hidden="true"></span>' : '') + (live ? 'live console' : 'agent console') +
|
||||||
'<span class="cc-chevron" aria-hidden="true">' + (isOpen ? '\\u25be' : '\\u25b8') + '</span>' +
|
'<span class="cc-chevron" aria-hidden="true">' + (isOpen ? '\\u25be' : '\\u25b8') + '</span>' +
|
||||||
'</button>' +
|
'</button>' +
|
||||||
|
'<div class="card-console" data-console-for="' + esc(t.id) + '"' + (isOpen ? '' : ' hidden') + '>' +
|
||||||
|
'<div class="card-console-body" data-console-body="' + esc(t.id) + '"><div class="cc-empty">waiting for output…</div></div>' +
|
||||||
|
'</div>' +
|
||||||
'</div>'
|
'</div>'
|
||||||
: '';
|
: '';
|
||||||
// Progress ring on in-progress (work time) AND review cards (wait time) —
|
// Progress ring on in-progress (work time) AND review cards (wait time) —
|
||||||
@ -467,74 +461,13 @@ export function columnsJs(opts: ColumnsJsOpts): string {
|
|||||||
body.insertAdjacentHTML('beforeend', consoleLineHtml(entry));
|
body.insertAdjacentHTML('beforeend', consoleLineHtml(entry));
|
||||||
body.scrollTop = body.scrollHeight;
|
body.scrollTop = body.scrollHeight;
|
||||||
}
|
}
|
||||||
// ── Console dropdown overlays ────────────────────────────────────────────
|
|
||||||
// The console is NOT rendered inside the card anymore (the card grid cell
|
|
||||||
// is far too narrow for log lines, and .card{overflow:hidden} would clip
|
|
||||||
// any absolute panel). Instead each open console gets ONE overlay panel on
|
|
||||||
// <body>, position:fixed, anchored under its card and spanning the full
|
|
||||||
// column width — readable logs regardless of how many cards share a row.
|
|
||||||
function consoleOverlay(id) {
|
|
||||||
return document.querySelector('.console-overlay[data-console-for="' + id + '"]');
|
|
||||||
}
|
|
||||||
function ensureConsoleOverlay(id) {
|
|
||||||
var ov = consoleOverlay(id);
|
|
||||||
if (ov) return ov;
|
|
||||||
ov = document.createElement('div');
|
|
||||||
ov.className = 'console-overlay';
|
|
||||||
ov.setAttribute('data-console-for', id);
|
|
||||||
ov.innerHTML =
|
|
||||||
'<div class="console-overlay-head">' +
|
|
||||||
'<span class="console-overlay-title">' + esc(id) + ' · console</span>' +
|
|
||||||
'<button class="console-overlay-close" type="button" data-console-close="' + esc(id) + '" aria-label="Close console">×</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="card-console-body" data-console-body="' + esc(id) + '"><div class="cc-empty">waiting for output…</div></div>';
|
|
||||||
document.body.appendChild(ov);
|
|
||||||
return ov;
|
|
||||||
}
|
|
||||||
function positionConsoleOverlay(id) {
|
|
||||||
var tog = document.querySelector('[data-console-toggle="' + id + '"]');
|
|
||||||
var ov = consoleOverlay(id);
|
|
||||||
if (!tog || !ov) return;
|
|
||||||
var card = tog.closest('.card');
|
|
||||||
var column = tog.closest('.column');
|
|
||||||
if (!card || !column) return;
|
|
||||||
var inset = 13; // column padding (12) + border (1)
|
|
||||||
var colRect = column.getBoundingClientRect();
|
|
||||||
var cardRect = card.getBoundingClientRect();
|
|
||||||
ov.style.left = Math.round(colRect.left + inset) + 'px';
|
|
||||||
ov.style.width = Math.round(colRect.width - inset * 2) + 'px';
|
|
||||||
ov.style.top = Math.round(cardRect.bottom + 6) + 'px';
|
|
||||||
}
|
|
||||||
function openConsole(id) {
|
|
||||||
ensureConsoleOverlay(id);
|
|
||||||
positionConsoleOverlay(id);
|
|
||||||
loadConsole(id);
|
|
||||||
}
|
|
||||||
function closeConsole(id) {
|
|
||||||
var ov = consoleOverlay(id);
|
|
||||||
if (ov) ov.remove();
|
|
||||||
}
|
|
||||||
// After every board re-render / scroll / resize: keep the open overlays
|
|
||||||
// glued to their (possibly moved) cards; close orphans whose card is gone.
|
|
||||||
function repositionConsoles() {
|
|
||||||
Object.keys(openConsoles).forEach(function(id) {
|
|
||||||
if (!openConsoles[id]) return;
|
|
||||||
if (!document.querySelector('[data-console-toggle="' + id + '"]')) {
|
|
||||||
openConsoles[id] = false;
|
|
||||||
closeConsole(id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ensureConsoleOverlay(id);
|
|
||||||
positionConsoleOverlay(id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-open any consoles that were expanded before a re-render (cards rebuild
|
// Re-open any consoles that were expanded before a re-render (cards rebuild
|
||||||
// their innerHTML, so the panel state must be re-applied + reloaded).
|
// their innerHTML, so the panel state must be re-applied + reloaded).
|
||||||
function reapplyConsoles() {
|
function reapplyConsoles() {
|
||||||
repositionConsoles();
|
|
||||||
Object.keys(openConsoles).forEach(function(id) {
|
Object.keys(openConsoles).forEach(function(id) {
|
||||||
if (openConsoles[id]) loadConsole(id);
|
if (!openConsoles[id]) return;
|
||||||
|
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
|
||||||
|
if (panel) { panel.hidden = false; loadConsole(id); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function setConn(state, label) {
|
function setConn(state, label) {
|
||||||
@ -630,7 +563,6 @@ export function columnsJs(opts: ColumnsJsOpts): string {
|
|||||||
var data = null;
|
var data = null;
|
||||||
try { data = JSON.parse(ev && ev.data); type = (data || {}).type || ''; } catch (_) {}
|
try { data = JSON.parse(ev && ev.data); type = (data || {}).type || ''; } catch (_) {}
|
||||||
if (type === 'task') { refresh(); feedFromEvent(data); }
|
if (type === 'task') { refresh(); feedFromEvent(data); }
|
||||||
if (type === 'agent' && window.refreshAgentHealth) window.refreshAgentHealth();
|
|
||||||
refreshBudget();
|
refreshBudget();
|
||||||
};
|
};
|
||||||
source.onerror = function() {
|
source.onerror = function() {
|
||||||
@ -692,11 +624,6 @@ export function columnsJs(opts: ColumnsJsOpts): string {
|
|||||||
if (!res.ok) throw new Error('create failed (' + res.status + ')');
|
if (!res.ok) throw new Error('create failed (' + res.status + ')');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
async function recordTask(body) {
|
|
||||||
var res = await fetch('/tasks/record', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
||||||
if (!res.ok) throw new Error('record failed (' + res.status + ')');
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
async function patchTask(id, body) {
|
async function patchTask(id, body) {
|
||||||
var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -706,13 +633,6 @@ export function columnsJs(opts: ColumnsJsOpts): string {
|
|||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
async function dispatchTask(id, agent) {
|
|
||||||
var res = await fetch('/tasks/' + encodeURIComponent(id) + '/dispatch', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agent: agent })
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('dispatch failed (' + res.status + ')');
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
function flashCard(id) {
|
function flashCard(id) {
|
||||||
var el = document.querySelector('.card[data-id="' + id + '"]');
|
var el = document.querySelector('.card[data-id="' + id + '"]');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@ -741,14 +661,8 @@ export function columnsJs(opts: ColumnsJsOpts): string {
|
|||||||
// in the title, else mark it as a manual board claim.
|
// in the title, else mark it as a manual board claim.
|
||||||
var titledAgent = assigned ? '' : agentFromTitle(id);
|
var titledAgent = assigned ? '' : agentFromTitle(id);
|
||||||
var agent = assigned || titledAgent || 'manual';
|
var agent = assigned || titledAgent || 'manual';
|
||||||
var rosterAgent = (AGENTS || []).find(function(a) { return a.name === agent; });
|
await patchTask(id, { status: 'in_progress', assignedTo: agent });
|
||||||
if (rosterAgent && rosterAgent.dispatch === 'architect') {
|
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : ''));
|
||||||
await dispatchTask(id, agent);
|
|
||||||
toast(id + ' \\u2192 architect started @' + agent);
|
|
||||||
} else {
|
|
||||||
await patchTask(id, { status: 'in_progress', assignedTo: agent });
|
|
||||||
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : ''));
|
|
||||||
}
|
|
||||||
} else if (status === 'review') {
|
} else if (status === 'review') {
|
||||||
var reviewed = await patchTask(id, { status: 'review' });
|
var reviewed = await patchTask(id, { status: 'review' });
|
||||||
toast(id + ' \\u2192 review' + (reviewed && reviewed.reviewer ? ' \\u00b7 reviewed by @' + reviewed.reviewer : ''));
|
toast(id + ' \\u2192 review' + (reviewed && reviewed.reviewer ? ' \\u00b7 reviewed by @' + reviewed.reviewer : ''));
|
||||||
@ -895,67 +809,24 @@ export function columnsJs(opts: ColumnsJsOpts): string {
|
|||||||
}).then(function(ok) { if (ok) deleteTask(id); });
|
}).then(function(ok) { if (ok) deleteTask(id); });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clicks/selection inside the console overlay must not trigger card behavior.
|
// Clicks/selection inside the console body must not navigate to the task page.
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (e.target.closest && e.target.closest('.console-overlay')) e.preventDefault();
|
if (e.target.closest && e.target.closest('.card-console')) e.preventDefault();
|
||||||
});
|
});
|
||||||
// Live-console toggle lives inside the card <a> — stop the navigation.
|
// Live-console toggle lives inside the card <a> — stop the navigation.
|
||||||
// Only ONE console overlay at a time: opening one closes any other.
|
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
var tog = e.target.closest && e.target.closest('[data-console-toggle]');
|
var tog = e.target.closest && e.target.closest('[data-console-toggle]');
|
||||||
if (!tog) return;
|
if (!tog) return;
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
var id = tog.getAttribute('data-console-toggle');
|
var id = tog.getAttribute('data-console-toggle');
|
||||||
var willOpen = !openConsoles[id];
|
var willOpen = !openConsoles[id];
|
||||||
if (willOpen) {
|
|
||||||
Object.keys(openConsoles).forEach(function(otherId) {
|
|
||||||
if (otherId === id || !openConsoles[otherId]) return;
|
|
||||||
openConsoles[otherId] = false;
|
|
||||||
var ot = document.querySelector('[data-console-toggle="' + otherId + '"]');
|
|
||||||
if (ot) {
|
|
||||||
ot.setAttribute('aria-expanded', 'false');
|
|
||||||
var oc = ot.querySelector('.cc-chevron'); if (oc) oc.textContent = '\\u25b8';
|
|
||||||
}
|
|
||||||
closeConsole(otherId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
openConsoles[id] = willOpen;
|
openConsoles[id] = willOpen;
|
||||||
tog.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
tog.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
||||||
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = willOpen ? '\\u25be' : '\\u25b8';
|
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = willOpen ? '\\u25be' : '\\u25b8';
|
||||||
if (willOpen) openConsole(id);
|
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
|
||||||
else closeConsole(id);
|
if (panel) panel.hidden = !willOpen;
|
||||||
|
if (willOpen) loadConsole(id);
|
||||||
});
|
});
|
||||||
// The overlay's own close button.
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
var btn = e.target.closest && e.target.closest('[data-console-close]');
|
|
||||||
if (!btn) return;
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
var id = btn.getAttribute('data-console-close');
|
|
||||||
openConsoles[id] = false;
|
|
||||||
var tog = document.querySelector('[data-console-toggle="' + id + '"]');
|
|
||||||
if (tog) {
|
|
||||||
tog.setAttribute('aria-expanded', 'false');
|
|
||||||
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = '\\u25b8';
|
|
||||||
}
|
|
||||||
closeConsole(id);
|
|
||||||
});
|
|
||||||
// Dropdown behavior: a pointerdown OUTSIDE overlay + toggle closes it.
|
|
||||||
document.addEventListener('pointerdown', function(e) {
|
|
||||||
if (e.target.closest && (e.target.closest('.console-overlay') || e.target.closest('[data-console-toggle]'))) return;
|
|
||||||
Object.keys(openConsoles).forEach(function(id) {
|
|
||||||
if (!openConsoles[id]) return;
|
|
||||||
openConsoles[id] = false;
|
|
||||||
var tog = document.querySelector('[data-console-toggle="' + id + '"]');
|
|
||||||
if (tog) {
|
|
||||||
tog.setAttribute('aria-expanded', 'false');
|
|
||||||
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = '\\u25b8';
|
|
||||||
}
|
|
||||||
closeConsole(id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Keep overlays glued to their cards on scroll (any container) + resize.
|
|
||||||
document.addEventListener('scroll', repositionConsoles, true);
|
|
||||||
window.addEventListener('resize', repositionConsoles);
|
|
||||||
|
|
||||||
// ── Task-detail modal ──────────────────────────────────────────────────
|
// ── Task-detail modal ──────────────────────────────────────────────────
|
||||||
(function() {
|
(function() {
|
||||||
@ -974,13 +845,6 @@ export function columnsJs(opts: ColumnsJsOpts): string {
|
|||||||
var form = document.getElementById('tmForm');
|
var form = document.getElementById('tmForm');
|
||||||
if (!btn || !modal || !form) return;
|
if (!btn || !modal || !form) return;
|
||||||
var titleInput = document.getElementById('tmTitleInput');
|
var titleInput = document.getElementById('tmTitleInput');
|
||||||
var externalInput = document.getElementById('tmExternal');
|
|
||||||
var externalBy = document.getElementById('tmExternalBy');
|
|
||||||
var externalByWrap = document.getElementById('tmExternalByWrap');
|
|
||||||
externalInput.addEventListener('change', function() {
|
|
||||||
externalByWrap.hidden = !externalInput.checked;
|
|
||||||
externalBy.required = externalInput.checked;
|
|
||||||
});
|
|
||||||
function openModal() {
|
function openModal() {
|
||||||
modal.hidden = false;
|
modal.hidden = false;
|
||||||
btn.setAttribute('aria-expanded', 'true');
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
@ -1004,10 +868,8 @@ export function columnsJs(opts: ColumnsJsOpts): string {
|
|||||||
var priority = document.getElementById('tmPriority').value || undefined;
|
var priority = document.getElementById('tmPriority').value || undefined;
|
||||||
try {
|
try {
|
||||||
// No assignee/role: the task lands open in the architect's lap to route.
|
// No assignee/role: the task lands open in the architect's lap to route.
|
||||||
var task = externalInput.checked
|
var task = await postTask({ title: title, description: description, priority: priority });
|
||||||
? await recordTask({ title: title, description: description, priority: priority, doneBy: (externalBy.value || '').trim() })
|
toast(task.id + ' created');
|
||||||
: await postTask({ title: title, description: description, priority: priority });
|
|
||||||
toast(task.id + (externalInput.checked ? ' recorded as external work' : ' created'));
|
|
||||||
closeModal();
|
closeModal();
|
||||||
await refresh(); await refreshBudget(); flashCard(task.id);
|
await refresh(); await refreshBudget(); flashCard(task.id);
|
||||||
} catch (err) { toast(err.message || 'create failed', { error: true }); }
|
} catch (err) { toast(err.message || 'create failed', { error: true }); }
|
||||||
|
|||||||
@ -46,13 +46,6 @@ ${backlogSeries.toString()}
|
|||||||
${areaPath.toString()}
|
${areaPath.toString()}
|
||||||
${laneChips.toString()}
|
${laneChips.toString()}
|
||||||
window.__b2UpdateKpis = function (tasks, agentColor) {
|
window.__b2UpdateKpis = function (tasks, agentColor) {
|
||||||
// Compact durations keep chips short: 25m → 3h → 4d.
|
|
||||||
function fmtMin(m) {
|
|
||||||
if (m < 60) return m + 'm';
|
|
||||||
var h = Math.round(m / 60);
|
|
||||||
if (h < 48) return h + 'h';
|
|
||||||
return Math.round(h / 24) + 'd';
|
|
||||||
}
|
|
||||||
var kpiPrev = window.__b2KpiPrev || (window.__b2KpiPrev = {});
|
var kpiPrev = window.__b2KpiPrev || (window.__b2KpiPrev = {});
|
||||||
function flashKpi(card, value) {
|
function flashKpi(card, value) {
|
||||||
if (kpiPrev[card] !== undefined && kpiPrev[card] !== value) {
|
if (kpiPrev[card] !== undefined && kpiPrev[card] !== value) {
|
||||||
@ -89,9 +82,7 @@ window.__b2UpdateKpis = function (tasks, agentColor) {
|
|||||||
el.innerHTML = capped.visible.map(function (c) {
|
el.innerHTML = capped.visible.map(function (c) {
|
||||||
var color = agentColor ? agentColor(c.name) : '#a5b4fc';
|
var color = agentColor ? agentColor(c.name) : '#a5b4fc';
|
||||||
var initial = c.name.charAt(0).toUpperCase();
|
var initial = c.name.charAt(0).toUpperCase();
|
||||||
// No text prefix — keeps chips narrow enough for a single line (the
|
var time = c.minutes == null ? '' : (status === 'review' ? 'prüft ' : '') + c.minutes + 'm';
|
||||||
// card label already says REVIEW / IN PROGRESS).
|
|
||||||
var time = c.minutes == null ? '' : fmtMin(c.minutes);
|
|
||||||
var dot = status === 'in_progress' ? '<span class="b2-cdot"></span>' : '';
|
var dot = status === 'in_progress' ? '<span class="b2-cdot"></span>' : '';
|
||||||
return '<span class="b2-achip"><i style="background:' + color + '">' + initial + '</i>' + dot + time + '</span>';
|
return '<span class="b2-achip"><i style="background:' + color + '">' + initial + '</i>' + dot + time + '</span>';
|
||||||
}).join('') + (capped.hidden > 0 ? '<span class="b2-achip b2-more">+' + capped.hidden + '</span>' : '');
|
}).join('') + (capped.hidden > 0 ? '<span class="b2-achip b2-more">+' + capped.hidden + '</span>' : '');
|
||||||
|
|||||||
@ -22,10 +22,6 @@ export function sidebarHtml(): string {
|
|||||||
</div>
|
</div>
|
||||||
<div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div>
|
<div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="b2-panel b2-glass" id="b2AgentsPanel">
|
|
||||||
<h6>Agents</h6>
|
|
||||||
<div class="b2-agents" id="b2Agents"><div class="budget-empty">no agents yet</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="b2-panel b2-glass">
|
<div class="b2-panel b2-glass">
|
||||||
<h6>Live</h6>
|
<h6>Live</h6>
|
||||||
<div class="b2-feed" id="b2Feed"></div>
|
<div class="b2-feed" id="b2Feed"></div>
|
||||||
@ -65,35 +61,5 @@ window.__b2FeedPush = function (html) {
|
|||||||
feed.insertBefore(div, feed.firstChild);
|
feed.insertBefore(div, feed.firstChild);
|
||||||
while (feed.children.length > 5) feed.removeChild(feed.lastChild);
|
while (feed.children.length > 5) feed.removeChild(feed.lastChild);
|
||||||
};
|
};
|
||||||
// ── Agent health traffic lights (GET /health) ─────────────────────────────
|
|
||||||
// active (green) <2min since last action · busy (blue, on TSK-X) ·
|
|
||||||
// idle (gray) · stale/offline (red, >10min with an open assignment).
|
|
||||||
window.__b2RenderAgents = function (report) {
|
|
||||||
var box = document.getElementById('b2Agents');
|
|
||||||
if (!box) return;
|
|
||||||
var agents = report && report.agents ? report.agents : [];
|
|
||||||
if (!agents.length) { box.innerHTML = '<div class="budget-empty">no agents yet</div>'; return; }
|
|
||||||
box.innerHTML = agents.map(function (a) {
|
|
||||||
var loop = a.dispatch === 'architect'
|
|
||||||
? (a.taskId ? 'architect started' : a.waitingTaskId ? 'wartet auf Architekten-Start' : 'architect dispatch')
|
|
||||||
: a.inLoop
|
|
||||||
? 'loop ' + compact(a.loopSinceAgoSec || 0)
|
|
||||||
: a.loopExitReason
|
|
||||||
? 'out ' + compact(a.loopExitAgoSec || 0) + ' · ' + a.loopExitReason
|
|
||||||
: 'loop ?';
|
|
||||||
var work = a.taskId ? a.taskId : a.waitingTaskId ? a.waitingTaskId + ' waiting' : '';
|
|
||||||
var detail = loop + (work ? ' · ' + work : '');
|
|
||||||
return '<div class="b2-agent-row" title="' + esc(a.name) + ': ' + esc(detail) + '">' +
|
|
||||||
'<span class="b2-light st-' + esc(a.state) + '" aria-hidden="true"></span>' +
|
|
||||||
'<span class="b2-agent-name">' + esc(a.name) + '</span>' +
|
|
||||||
'<span class="b2-agent-state">' + esc(detail) + '</span></div>';
|
|
||||||
}).join('');
|
|
||||||
};
|
|
||||||
async function refreshAgentHealth() {
|
|
||||||
try { window.__b2RenderAgents(await getJSON('/health')); } catch (_) {}
|
|
||||||
}
|
|
||||||
window.refreshAgentHealth = refreshAgentHealth;
|
|
||||||
refreshAgentHealth();
|
|
||||||
setInterval(refreshAgentHealth, 5000);
|
|
||||||
${v1BudgetJs}`;
|
${v1BudgetJs}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -95,7 +95,7 @@ export function boardV2Css(): string {
|
|||||||
.b2-val small { font-size: 13px; color: var(--b2-muted); font-weight: 500; }
|
.b2-val small { font-size: 13px; color: var(--b2-muted); font-weight: 500; }
|
||||||
.b2-miniarea { margin-top: 8px; }
|
.b2-miniarea { margin-top: 8px; }
|
||||||
.b2-spark { stroke-dasharray: 400; stroke-dashoffset: 400; animation: b2-draw 1.6s .3s forwards ease-out; }
|
.b2-spark { stroke-dasharray: 400; stroke-dashoffset: 400; animation: b2-draw 1.6s .3s forwards ease-out; }
|
||||||
.b2-chips { display: flex; gap: 6px; margin-top: 10px; align-items: center; flex-wrap: nowrap; overflow: hidden; min-height: 24px; }
|
.b2-chips { display: flex; gap: 6px; margin-top: 10px; align-items: center; flex-wrap: wrap; min-height: 24px; }
|
||||||
.b2-achip {
|
.b2-achip {
|
||||||
display: inline-flex; align-items: center; gap: 5px;
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
font: 11px/1 var(--mono); padding: 3px 9px 3px 4px;
|
font: 11px/1 var(--mono); padding: 3px 9px 3px 4px;
|
||||||
@ -215,8 +215,9 @@ export function boardV2Css(): string {
|
|||||||
.card:hover .card-del, .card:focus-within .card-del { opacity: 1; }
|
.card:hover .card-del, .card:focus-within .card-del { opacity: 1; }
|
||||||
.card-del:hover { color: #fca5a5; border-color: rgba(239,68,68,.5); background: rgba(239,68,68,.12); }
|
.card-del:hover { color: #fca5a5; border-color: rgba(239,68,68,.5); background: rgba(239,68,68,.12); }
|
||||||
|
|
||||||
/* ── Console dropdown overlay (full column width, floats above cards) ── */
|
/* ── In-card live console (ported) ────────────────────────────────── */
|
||||||
.card-console-wrap { margin-top: 9px; }
|
.card-console-wrap { margin-top: 9px; }
|
||||||
|
.card:has(.card-console:not([hidden])) { min-height: 230px; }
|
||||||
.card-console-toggle { display: inline-flex; align-items: center; gap: 6px;
|
.card-console-toggle { display: inline-flex; align-items: center; gap: 6px;
|
||||||
background: transparent; border: 1px solid var(--b2-border); color: var(--b2-muted);
|
background: transparent; border: 1px solid var(--b2-border); color: var(--b2-muted);
|
||||||
font: 700 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em;
|
font: 700 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em;
|
||||||
@ -227,27 +228,14 @@ export function boardV2Css(): string {
|
|||||||
box-shadow: 0 0 0 3px rgba(56,189,248,.16); animation: ccPulse 1.6s ease-in-out infinite; }
|
box-shadow: 0 0 0 3px rgba(56,189,248,.16); animation: ccPulse 1.6s ease-in-out infinite; }
|
||||||
.card-console-toggle .cc-chevron { font-size: 9px; opacity: .75; }
|
.card-console-toggle .cc-chevron { font-size: 9px; opacity: .75; }
|
||||||
@keyframes ccPulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
|
@keyframes ccPulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
|
||||||
/* The panel itself lives on <body> (position:fixed, JS-anchored under its
|
.card-console { margin-top: 7px; }
|
||||||
card) so it escapes .card{overflow:hidden} and the narrow grid cell. */
|
.card-console[hidden] { display: none; }
|
||||||
.console-overlay { position: fixed; z-index: 70; background: var(--b2-raised);
|
.card-console-body { max-height: 180px; overflow-y: auto; background: rgba(0,0,0,.28);
|
||||||
border: 1px solid var(--b2-border); border-radius: 10px; padding: 8px 10px 10px;
|
|
||||||
box-shadow: 0 18px 44px rgba(2,6,18,.55); animation: consoleDrop 160ms ease-out; }
|
|
||||||
@keyframes consoleDrop { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
||||||
.console-overlay-head { display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
|
||||||
margin-bottom: 6px; font: 700 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--b2-muted); }
|
|
||||||
.console-overlay-close { border: 1px solid var(--b2-border); background: transparent; color: var(--b2-muted);
|
|
||||||
width: 18px; height: 18px; border-radius: 50%; font: 12px/1 var(--mono); cursor: pointer; display: inline-grid; place-items: center; }
|
|
||||||
.console-overlay-close:hover { color: var(--text); border-color: var(--accent); }
|
|
||||||
.card-console-body { max-height: 260px; overflow-y: auto; background: rgba(0,0,0,.28);
|
|
||||||
border: 1px solid var(--b2-border); border-radius: 8px; padding: 8px 10px;
|
border: 1px solid var(--b2-border); border-radius: 8px; padding: 8px 10px;
|
||||||
font: 11px/1.55 var(--mono); color: #cbd5e1; }
|
font: 11px/1.55 var(--mono); color: #cbd5e1; }
|
||||||
.cc-line { display: flex; gap: 8px; white-space: pre-wrap; overflow-wrap: anywhere; padding: 1px 0; }
|
.cc-line { display: flex; gap: 8px; white-space: pre-wrap; overflow-wrap: anywhere; padding: 1px 0; }
|
||||||
.cc-line .cc-ts { color: var(--b2-muted); opacity: .8; flex: 0 0 auto; }
|
.cc-line .cc-ts { color: var(--b2-muted); opacity: .8; flex: 0 0 auto; }
|
||||||
.cc-line .cc-agent { color: var(--accent); font-weight: 600; flex: 0 0 auto; }
|
.cc-line .cc-agent { color: var(--accent); font-weight: 600; flex: 0 0 auto; }
|
||||||
/* flex:1 + min-width:0 — the text must claim the line's remaining width and
|
|
||||||
wrap inside it. Without this, overflow-wrap:anywhere collapses the flex
|
|
||||||
item to its min-content width (~1 char) and the log renders vertically. */
|
|
||||||
.cc-line .cc-text { flex: 1 1 auto; min-width: 0; }
|
|
||||||
.cc-line.level-error .cc-text { color: #fca5a5; }
|
.cc-line.level-error .cc-text { color: #fca5a5; }
|
||||||
.cc-line.level-warn .cc-text { color: #fcd34d; }
|
.cc-line.level-warn .cc-text { color: #fcd34d; }
|
||||||
.cc-line.level-bridge .cc-text { color: #c4b5fd; }
|
.cc-line.level-bridge .cc-text { color: #c4b5fd; }
|
||||||
@ -335,17 +323,6 @@ export function boardV2Css(): string {
|
|||||||
.b2-feed .pulse { color: var(--b2-green); animation: b2-blink 1.6s infinite; }
|
.b2-feed .pulse { color: var(--b2-green); animation: b2-blink 1.6s infinite; }
|
||||||
.b2-feed .f-time { color: var(--b2-dim); font-size: 10px; margin-left: auto; flex: 0 0 auto; }
|
.b2-feed .f-time { color: var(--b2-dim); font-size: 10px; margin-left: auto; flex: 0 0 auto; }
|
||||||
|
|
||||||
/* ── Agent health lights ──────────────────────────────────────────── */
|
|
||||||
.b2-agents { margin-top: 8px; display: grid; gap: 2px; }
|
|
||||||
.b2-agent-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font: 12px/1.3 var(--mono); }
|
|
||||||
.b2-light { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; background: #94a3b8; }
|
|
||||||
.b2-light.st-active { background: var(--b2-green); box-shadow: 0 0 0 3px rgba(52,211,153,.16); }
|
|
||||||
.b2-light.st-busy { background: var(--accent); box-shadow: 0 0 0 3px rgba(56,189,248,.16); }
|
|
||||||
.b2-light.st-idle { background: #94a3b8; }
|
|
||||||
.b2-light.st-stale { background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,.16); }
|
|
||||||
.b2-agent-name { color: var(--text); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.b2-agent-state { margin-left: auto; color: var(--b2-muted); font-size: 10.5px; white-space: nowrap; }
|
|
||||||
|
|
||||||
/* ── Modals (ported v1) ───────────────────────────────────────────── */
|
/* ── Modals (ported v1) ───────────────────────────────────────────── */
|
||||||
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start;
|
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start;
|
||||||
justify-content: center; padding: 12vh 16px 16px; background: rgba(2,6,18,.62);
|
justify-content: center; padding: 12vh 16px 16px; background: rgba(2,6,18,.62);
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
import Database from 'better-sqlite3';
|
|
||||||
import { mkdirSync } from 'node:fs';
|
|
||||||
import { dirname } from 'node:path';
|
|
||||||
import { getIndexPath } from '../core/paths.js';
|
|
||||||
import type { AgentHubEvent } from './events.js';
|
|
||||||
|
|
||||||
export interface LoggedAgentHubEvent extends AgentHubEvent {
|
|
||||||
seq: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedPath: string | undefined;
|
|
||||||
let cachedDb: Database.Database | undefined;
|
|
||||||
|
|
||||||
function dbFor(cwd: string): Database.Database {
|
|
||||||
const path = getIndexPath(cwd);
|
|
||||||
if (cachedDb && cachedPath === path) return cachedDb;
|
|
||||||
cachedDb?.close();
|
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
|
||||||
const db = new Database(path);
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS hub_events (
|
|
||||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
createdAt TEXT NOT NULL,
|
|
||||||
eventJson TEXT NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
cachedPath = path;
|
|
||||||
cachedDb = db;
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendHubEvent(cwd: string, event: AgentHubEvent): LoggedAgentHubEvent {
|
|
||||||
const eventJson = JSON.stringify({ ...event, seq: undefined });
|
|
||||||
const info = dbFor(cwd)
|
|
||||||
.prepare('INSERT INTO hub_events (createdAt, eventJson) VALUES (@createdAt, @eventJson)')
|
|
||||||
.run({ createdAt: new Date().toISOString(), eventJson });
|
|
||||||
return { ...event, seq: Number(info.lastInsertRowid) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listHubEventsAfter(cwd: string, afterSeq: number, limit = 1000): LoggedAgentHubEvent[] {
|
|
||||||
const rows = dbFor(cwd)
|
|
||||||
.prepare('SELECT seq, eventJson FROM hub_events WHERE seq > @afterSeq ORDER BY seq ASC LIMIT @limit')
|
|
||||||
.all({ afterSeq, limit }) as Array<{ seq: number; eventJson: string }>;
|
|
||||||
const events: LoggedAgentHubEvent[] = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
try {
|
|
||||||
events.push({ ...(JSON.parse(row.eventJson) as AgentHubEvent), seq: row.seq });
|
|
||||||
} catch {
|
|
||||||
// Ignore corrupt historical rows; new appends always write valid JSON.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countHubEventsAfter(cwd: string, afterSeq: number): number {
|
|
||||||
const row = dbFor(cwd)
|
|
||||||
.prepare('SELECT COUNT(*) AS count FROM hub_events WHERE seq > ?')
|
|
||||||
.get(afterSeq) as { count: number };
|
|
||||||
return row.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function latestHubEventSeq(cwd: string): number {
|
|
||||||
const row = dbFor(cwd)
|
|
||||||
.prepare('SELECT COALESCE(MAX(seq), 0) AS seq FROM hub_events')
|
|
||||||
.get() as { seq: number };
|
|
||||||
return row.seq;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closeHubEventLogForTests(): void {
|
|
||||||
cachedDb?.close();
|
|
||||||
cachedDb = undefined;
|
|
||||||
cachedPath = undefined;
|
|
||||||
}
|
|
||||||
@ -1,12 +1,9 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { appendHubEvent } from './eventLog.js';
|
|
||||||
|
|
||||||
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'ask' | 'agent';
|
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'ask' | 'agent';
|
||||||
export type AgentHubEventAction = 'created' | 'updated' | 'deleted' | 'joined' | 'left';
|
export type AgentHubEventAction = 'created' | 'updated' | 'deleted' | 'joined' | 'left';
|
||||||
|
|
||||||
export interface AgentHubEvent {
|
export interface AgentHubEvent {
|
||||||
/** Monotonic durable sequence id for replayable hub-change events. */
|
|
||||||
seq?: number;
|
|
||||||
type: AgentHubEventType;
|
type: AgentHubEventType;
|
||||||
action: AgentHubEventAction;
|
action: AgentHubEventAction;
|
||||||
/** Entity id, or — for `agent` presence events — the agent name. */
|
/** Entity id, or — for `agent` presence events — the agent name. */
|
||||||
@ -15,8 +12,6 @@ export interface AgentHubEvent {
|
|||||||
status?: string;
|
status?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
assignedTo?: string;
|
assignedTo?: string;
|
||||||
/** Absender bei message-Events — erlaubt Empfängern, System-Post zu filtern. */
|
|
||||||
from?: string;
|
|
||||||
claimedBy?: string;
|
claimedBy?: string;
|
||||||
reviewer?: string;
|
reviewer?: string;
|
||||||
}
|
}
|
||||||
@ -65,11 +60,6 @@ eventBus.setMaxListeners(0);
|
|||||||
// signature; the other path sees it via `seenRecently()` and stays silent.
|
// signature; the other path sees it via `seenRecently()` and stays silent.
|
||||||
const DEDUP_TTL_MS = 15_000;
|
const DEDUP_TTL_MS = 15_000;
|
||||||
const recentlyEmitted = new Map<string, number>();
|
const recentlyEmitted = new Map<string, number>();
|
||||||
let durableEventCwd: string | undefined;
|
|
||||||
|
|
||||||
export function configureDurableEvents(cwd: string): void {
|
|
||||||
durableEventCwd = cwd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stable key for a single logical mutation of one entity revision. */
|
/** Stable key for a single logical mutation of one entity revision. */
|
||||||
export function signatureOf(type: string, id: string, stamp: string | undefined): string {
|
export function signatureOf(type: string, id: string, stamp: string | undefined): string {
|
||||||
@ -106,13 +96,5 @@ export function seenRecently(signature: string): boolean {
|
|||||||
*/
|
*/
|
||||||
export function emitChange(event: AgentHubEvent, stamp: string | undefined): void {
|
export function emitChange(event: AgentHubEvent, stamp: string | undefined): void {
|
||||||
markEmitted(signatureOf(event.type, event.id, stamp));
|
markEmitted(signatureOf(event.type, event.id, stamp));
|
||||||
if (durableEventCwd) {
|
|
||||||
try {
|
|
||||||
eventBus.publish(appendHubEvent(durableEventCwd, event));
|
|
||||||
} catch {
|
|
||||||
eventBus.publish(event);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
eventBus.publish(event);
|
eventBus.publish(event);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,6 @@ import { readEntity } from '../core/files.js';
|
|||||||
import { getEntityDir, type EntityType } from '../core/paths.js';
|
import { getEntityDir, type EntityType } from '../core/paths.js';
|
||||||
import { emitChange, seenRecently, signatureOf } from './events.js';
|
import { emitChange, seenRecently, signatureOf } from './events.js';
|
||||||
import type { AgentHubEvent, AgentHubEventType } from './events.js';
|
import type { AgentHubEvent, AgentHubEventType } from './events.js';
|
||||||
import { TaskSchema } from '../core/schema.js';
|
|
||||||
import { toIndexEntry } from '../core/services/taskService.js';
|
|
||||||
import { Index } from '../core/index.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filesystem-watch layer.
|
* Filesystem-watch layer.
|
||||||
@ -35,17 +32,6 @@ const WATCHED: { dir: EntityType; type: AgentHubEventType }[] = [
|
|||||||
// fs.watch can fire several events (rename + change) for a single write, and a
|
// fs.watch can fire several events (rename + change) for a single write, and a
|
||||||
// file may be observed mid-write. Coalesce per-path bursts before reading.
|
// file may be observed mid-write. Coalesce per-path bursts before reading.
|
||||||
const DEBOUNCE_MS = 40;
|
const DEBOUNCE_MS = 40;
|
||||||
const RETRY_MS = 100;
|
|
||||||
const MAX_READ_ATTEMPTS = 3;
|
|
||||||
const indexErrors = new Map<string, { filePath: string; error: string; at: string }>();
|
|
||||||
|
|
||||||
export function getFsWatchErrors(): Array<{ filePath: string; error: string; at: string }> {
|
|
||||||
return [...indexErrors.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetFsWatchErrors(): void {
|
|
||||||
indexErrors.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
function str(value: unknown): string | undefined {
|
function str(value: unknown): string | undefined {
|
||||||
return value === undefined || value === null ? undefined : String(value);
|
return value === undefined || value === null ? undefined : String(value);
|
||||||
@ -118,15 +104,13 @@ export function startEntityWatcher(cwd: string): () => void {
|
|||||||
const key = `${type}:${name}`;
|
const key = `${type}:${name}`;
|
||||||
const existing = timers.get(key);
|
const existing = timers.get(key);
|
||||||
if (existing) clearTimeout(existing);
|
if (existing) clearTimeout(existing);
|
||||||
const filePath = getEntityDir(cwd, dir) + '/' + name;
|
timers.set(
|
||||||
const schedule = (attempt: number, delay: number) => {
|
key,
|
||||||
timers.set(key, setTimeout(() => {
|
setTimeout(() => {
|
||||||
timers.delete(key);
|
timers.delete(key);
|
||||||
const error = processFile(cwd, filePath, type);
|
processFile(getEntityDir(cwd, dir) + '/' + name, type);
|
||||||
if (error && attempt < MAX_READ_ATTEMPTS) schedule(attempt + 1, RETRY_MS);
|
}, DEBOUNCE_MS),
|
||||||
}, delay));
|
);
|
||||||
};
|
|
||||||
schedule(1, DEBOUNCE_MS);
|
|
||||||
});
|
});
|
||||||
watchers.push(watcher);
|
watchers.push(watcher);
|
||||||
} catch {
|
} catch {
|
||||||
@ -142,26 +126,13 @@ export function startEntityWatcher(cwd: string): () => void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function processFile(cwd: string, filePath: string, type: AgentHubEventType): Error | undefined {
|
function processFile(filePath: string, type: AgentHubEventType): void {
|
||||||
let fm: Record<string, unknown>;
|
let fm: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
const entity = readEntity(filePath);
|
fm = readEntity(filePath).frontmatter;
|
||||||
fm = entity.frontmatter;
|
} catch {
|
||||||
if (type === 'task') {
|
// Deleted again, or read mid-write -> skip; a later settled write re-fires.
|
||||||
const task = TaskSchema.parse(fm);
|
return;
|
||||||
const index = new Index(cwd);
|
|
||||||
try {
|
|
||||||
index.upsert(toIndexEntry(task, filePath));
|
|
||||||
} finally {
|
|
||||||
index.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
indexErrors.delete(filePath);
|
|
||||||
} catch (cause) {
|
|
||||||
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
||||||
indexErrors.set(filePath, { filePath, error: error.message, at: new Date().toISOString() });
|
|
||||||
console.error(`AgentHub fsWatch: could not reindex ${filePath}: ${error.message}`);
|
|
||||||
return error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const built = toEvent(type, fm);
|
const built = toEvent(type, fm);
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { resolveAdvertiseUrl, startDiscoveryBroadcaster } from '../discovery.js'
|
|||||||
import { startMdnsAdvertise, type MdnsHandle } from './mdns.js';
|
import { startMdnsAdvertise, type MdnsHandle } from './mdns.js';
|
||||||
import { startEntityWatcher } from './fsWatch.js';
|
import { startEntityWatcher } from './fsWatch.js';
|
||||||
import { startStatusAutoRefresh } from './statusRefresh.js';
|
import { startStatusAutoRefresh } from './statusRefresh.js';
|
||||||
import { startWatchdog } from './watchdog.js';
|
|
||||||
|
|
||||||
export function buildApp(cwd: string) {
|
export function buildApp(cwd: string) {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false });
|
||||||
@ -33,14 +32,11 @@ export async function startServer(cwd: string, options: { port?: number; host?:
|
|||||||
const stopWatcher = startEntityWatcher(cwd);
|
const stopWatcher = startEntityWatcher(cwd);
|
||||||
// Keep status/latest.md fresh on every change so agents never read a stale snapshot.
|
// Keep status/latest.md fresh on every change so agents never read a stale snapshot.
|
||||||
const stopStatusRefresh = startStatusAutoRefresh(cwd);
|
const stopStatusRefresh = startStatusAutoRefresh(cwd);
|
||||||
// Stuck-task watchdog: re-notify unclaimed priority tasks, flag silent ones.
|
|
||||||
const stopWatchdog = startWatchdog(cwd);
|
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
broadcaster?.stop();
|
broadcaster?.stop();
|
||||||
mdns?.stop();
|
mdns?.stop();
|
||||||
stopWatcher();
|
stopWatcher();
|
||||||
stopStatusRefresh();
|
stopStatusRefresh();
|
||||||
stopWatchdog();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { FastifyInstance, FastifyReply } from 'fastify';
|
import { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
import { createTask, recordExternalTask, dispatchTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask, deleteTask } from '../core/services/taskService.js';
|
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask, deleteTask } from '../core/services/taskService.js';
|
||||||
import { getTaskActivity } from '../core/services/activityService.js';
|
import { getTaskActivity } from '../core/services/activityService.js';
|
||||||
import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js';
|
import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js';
|
||||||
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
|
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
|
||||||
@ -11,27 +11,20 @@ import { getStatus, updateStatus } from '../core/services/statusService.js';
|
|||||||
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
|
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
|
||||||
import { computeBudget } from '../core/services/budgetService.js';
|
import { computeBudget } from '../core/services/budgetService.js';
|
||||||
import { getRoster } from '../core/services/rosterService.js';
|
import { getRoster } from '../core/services/rosterService.js';
|
||||||
import { computeHealth, enterLoop, leaveLoop, stampSeen, DORMANT_AFTER_MS } from '../core/services/presenceService.js';
|
|
||||||
import { resolvePending, hasPending } from '../core/services/checkinService.js';
|
|
||||||
import { setPreferredAgent } from '../core/services/roleService.js';
|
|
||||||
import { loadConfig, saveConfig } from '../core/config.js';
|
import { loadConfig, saveConfig } from '../core/config.js';
|
||||||
import { agentIdentity } from '../core/services/identityService.js';
|
|
||||||
import { renderActivityHtml } from './activity.js';
|
import { renderActivityHtml } from './activity.js';
|
||||||
import { renderAgentHealthHtml } from './agentHealth.js';
|
|
||||||
import { renderBoardHtml } from './board/index.js';
|
import { renderBoardHtml } from './board/index.js';
|
||||||
import { renderTeamHtml } from './team.js';
|
import { renderTeamHtml } from './team.js';
|
||||||
import { renderArchiveHtml } from './archive.js';
|
import { renderArchiveHtml } from './archive.js';
|
||||||
import { renderDecisionsHtml } from './decisions.js';
|
import { renderDecisionsHtml } from './decisions.js';
|
||||||
import { renderMessagesHtml } from './messages.js';
|
import { renderMessagesHtml } from './messages.js';
|
||||||
import { renderTaskDetailHtml } from './taskDetail.js';
|
import { renderTaskDetailHtml } from './taskDetail.js';
|
||||||
import { configureDurableEvents, eventBus, emitChange } from './events.js';
|
import { eventBus, emitChange } from './events.js';
|
||||||
import type { AgentHubEvent } from './events.js';
|
import type { AgentHubEvent } from './events.js';
|
||||||
import { countHubEventsAfter, latestHubEventSeq, listHubEventsAfter } from './eventLog.js';
|
|
||||||
import type { Task, Handoff, Decision, Memory, Message, Ask } from '../core/schema.js';
|
import type { Task, Handoff, Decision, Memory, Message, Ask } from '../core/schema.js';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { join, extname, normalize } from 'node:path';
|
import { join, extname, normalize } from 'node:path';
|
||||||
import { getFsWatchErrors } from './fsWatch.js';
|
|
||||||
|
|
||||||
const SSE_KEEPALIVE_MS = 10_000;
|
const SSE_KEEPALIVE_MS = 10_000;
|
||||||
|
|
||||||
@ -53,10 +46,6 @@ function wantsHtml(request: { headers: { accept?: string } }): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
|
export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
|
||||||
configureDurableEvents(cwd);
|
|
||||||
// Uptime anchor for /health (buildApp ≈ server start).
|
|
||||||
const startedAtMs = Date.now();
|
|
||||||
|
|
||||||
// Default dynamic responses to no-store so board reloads/fetches never reuse
|
// Default dynamic responses to no-store so board reloads/fetches never reuse
|
||||||
// stale task JSON or HTML. Static asset routes override this with cacheable
|
// stale task JSON or HTML. Static asset routes override this with cacheable
|
||||||
// headers, and the SSE route writes its own raw no-cache header.
|
// headers, and the SSE route writes its own raw no-cache header.
|
||||||
@ -64,130 +53,9 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
reply.header('Cache-Control', 'no-store, must-revalidate');
|
reply.header('Cache-Control', 'no-store, must-revalidate');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hub health: status + version + uptime + compact counts + per-agent lights.
|
|
||||||
app.get('/health', async () => ({
|
|
||||||
...computeHealth(cwd, startedAtMs),
|
|
||||||
indexErrors: getFsWatchErrors(),
|
|
||||||
}));
|
|
||||||
app.get('/roles', async () => loadConfig(cwd).roles);
|
|
||||||
app.patch('/roles/:role', async (request, reply) => {
|
|
||||||
const { role } = request.params as { role: string };
|
|
||||||
const { agent } = request.body as { agent?: string };
|
|
||||||
if (!agent) return badRequest(reply, 'agent is required');
|
|
||||||
try {
|
|
||||||
return setPreferredAgent(cwd, role, agent);
|
|
||||||
} catch (err) {
|
|
||||||
return badRequest(reply, err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
app.get('/architect/pulse', async (request) => {
|
|
||||||
const query = request.query as { sinceSeq?: string; since?: string };
|
|
||||||
const rawSince = query.sinceSeq ?? query.since;
|
|
||||||
const explicitSince = rawSince !== undefined;
|
|
||||||
const sinceSeq = Math.max(0, Number.parseInt(rawSince ?? '0', 10) || 0);
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
const architect = config.roles.architect?.preferredAgent ?? 'claude';
|
|
||||||
const health = computeHealth(cwd, startedAtMs);
|
|
||||||
const eventLimit = 5;
|
|
||||||
const eventBase = explicitSince ? sinceSeq : Math.max(0, latestHubEventSeq(cwd) - eventLimit);
|
|
||||||
const rawEvents = listHubEventsAfter(cwd, eventBase, eventLimit);
|
|
||||||
const events = rawEvents.map(({ seq, type, action, id, status, assignedTo }) => ({
|
|
||||||
seq, type, action, id, status, assignedTo,
|
|
||||||
}));
|
|
||||||
const nextSeq = rawEvents.length ? rawEvents[rawEvents.length - 1].seq : latestHubEventSeq(cwd);
|
|
||||||
const availableEvents = countHubEventsAfter(cwd, explicitSince ? sinceSeq : 0);
|
|
||||||
const eventsOmitted = Math.max(0, availableEvents - rawEvents.length);
|
|
||||||
const allMessages = listMessages(cwd)
|
|
||||||
.filter((message) => message.to === architect && message.status === 'unread');
|
|
||||||
const messageLimit = 5;
|
|
||||||
return {
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
nextSeq,
|
|
||||||
reviews: listTasks(cwd, { status: 'review' }).map((task) => ({
|
|
||||||
id: task.id,
|
|
||||||
assignedTo: task.assignedTo,
|
|
||||||
updatedAt: task.updatedAt,
|
|
||||||
})),
|
|
||||||
// Zwei verschiedene Zustände, die nicht gleich behandelt werden dürfen:
|
|
||||||
//
|
|
||||||
// a) `loopExitReason` gesetzt → der Agent hat den Loop AUSDRÜCKLICH
|
|
||||||
// verlassen ("turn ended"). Er hört ab sofort nicht mehr zu; ein
|
|
||||||
// Reopen erreicht ihn nie. Sofort melden, ohne Wartezeit.
|
|
||||||
// b) kein Exit-Grund, aber ein Task in Arbeit → der Agent FÜHRT gerade
|
|
||||||
// aus. `inLoop` ist dabei immer false; ohne Zeitbedingung wäre jeder
|
|
||||||
// hart arbeitende Agent "dormant" und der Architekt würde Leute
|
|
||||||
// anstoßen, die einwandfrei laufen. Erst nach DORMANT_AFTER_MS ohne
|
|
||||||
// Check-in ist das ein Fall für mich.
|
|
||||||
dormantAgents: health.agents
|
|
||||||
.filter((agent) => {
|
|
||||||
if (agent.inLoop) return false;
|
|
||||||
if (agent.loopExitReason) return true;
|
|
||||||
if (!agent.taskId) return false;
|
|
||||||
return agent.lastSeenAgoSec === undefined || agent.lastSeenAgoSec * 1000 >= DORMANT_AFTER_MS;
|
|
||||||
})
|
|
||||||
.map((agent) => ({
|
|
||||||
name: agent.name,
|
|
||||||
taskId: agent.taskId,
|
|
||||||
lastSeenAgoSec: agent.lastSeenAgoSec,
|
|
||||||
loopExitAgoSec: agent.loopExitAgoSec,
|
|
||||||
loopExitReason: agent.loopExitReason,
|
|
||||||
})),
|
|
||||||
// Der Fall, der den Menschen sonst zum Postman macht: eine Task ist an
|
|
||||||
// einen Agenten adressiert, aber er sitzt nicht im Work-Loop. Er ist dann
|
|
||||||
// nicht "dormant" (er lebt vielleicht sogar), hoert aber nicht zu — die
|
|
||||||
// Arbeit bleibt liegen, ohne dass irgendwo ein Fehler erscheint. Real
|
|
||||||
// erlebt: codex war `active` (75s zuvor gesehen) mit inLoop=false,
|
|
||||||
// waehrend TSK-0288 offen an ihn adressiert dalag.
|
|
||||||
notListening: health.agents
|
|
||||||
.filter((agent) => !agent.inLoop && agent.pendingCount > 0)
|
|
||||||
.map((agent) => ({
|
|
||||||
name: agent.name,
|
|
||||||
waitingTasks: agent.pendingCount,
|
|
||||||
taskId: agent.waitingTaskId,
|
|
||||||
lastSeenAgoSec: agent.lastSeenAgoSec,
|
|
||||||
hint: `${agent.name} hat offene Arbeit, sitzt aber nicht im Work-Loop — anstossen oder Session neu starten.`,
|
|
||||||
})),
|
|
||||||
messages: allMessages.slice(0, messageLimit).map(({ id, from, taskId, createdAt }) => ({
|
|
||||||
id, from, taskId, createdAt,
|
|
||||||
})),
|
|
||||||
events,
|
|
||||||
omitted: {
|
|
||||||
messages: Math.max(0, allMessages.length - messageLimit),
|
|
||||||
events: eventsOmitted,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
app.get('/agents/:agent/identity', async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const identity = agentIdentity(cwd, (request.params as { agent: string }).agent);
|
|
||||||
// workTimeoutSec gehoert fachlich zur Agenten-Identitaet: "wer bin ich und
|
|
||||||
// wie lange darf ich blockieren". Der MCP-Server holt es sich hier, statt
|
|
||||||
// dass jeder Client es raten muss.
|
|
||||||
let workTimeoutSec: number | undefined;
|
|
||||||
try {
|
|
||||||
workTimeoutSec = loadConfig(cwd).agents?.[identity.canonical]?.workTimeoutSec;
|
|
||||||
} catch { /* ohne Config bleibt der Default */ }
|
|
||||||
return { canonical: identity.canonical, names: [...identity.names], workTimeoutSec };
|
|
||||||
} catch (err) {
|
|
||||||
return badRequest(reply, err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Agent health-check page (TSK-0230): reachability traffic light per agent
|
|
||||||
// (same /health data), test-message send with live delivery tracking,
|
|
||||||
// cross-messaging view, and the page's own transport mode (SSE vs polling).
|
|
||||||
app.get('/agent-health', async (_request, reply) =>
|
|
||||||
reply.type('text/html; charset=utf-8').send(renderAgentHealthHtml(cwd, startedAtMs)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Static, self-contained Trello-like board. Polls /tasks, /handoffs and
|
// Static, self-contained Trello-like board. Polls /tasks, /handoffs and
|
||||||
// /decisions on the same origin; no build step, no deps. Cached once — the
|
// /decisions on the same origin; no build step, no deps. Cached once — the
|
||||||
// markup is constant, only the data it fetches changes.
|
// markup is constant, only the data it fetches changes.
|
||||||
// Wurzel-URL auf das Board leiten. Ohne das antwortet http://<host>:3377/
|
|
||||||
// mit 404 — wer "laeuft der Hub?" pruefen will, oeffnet aber genau diese
|
|
||||||
// Adresse und haelt den Hub faelschlich fuer tot (real passiert).
|
|
||||||
app.get('/', async (_request, reply) => reply.redirect('/board', 302));
|
|
||||||
|
|
||||||
const boardHtml = renderBoardHtml(loadConfig(cwd).projectName);
|
const boardHtml = renderBoardHtml(loadConfig(cwd).projectName);
|
||||||
app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml));
|
app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml));
|
||||||
|
|
||||||
@ -253,12 +121,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
// (events.ts) ensures each change is delivered exactly once.
|
// (events.ts) ensures each change is delivered exactly once.
|
||||||
app.get('/events', async (request, reply) => {
|
app.get('/events', async (request, reply) => {
|
||||||
const { role } = request.query as { role?: string };
|
const { role } = request.query as { role?: string };
|
||||||
const lastEventIdHeader = request.headers['last-event-id'];
|
|
||||||
const lastEventId =
|
|
||||||
typeof lastEventIdHeader === 'string'
|
|
||||||
? Number.parseInt(lastEventIdHeader, 10)
|
|
||||||
: Number.parseInt((request.query as { lastEventId?: string }).lastEventId ?? '', 10);
|
|
||||||
const replayAfterSeq = Number.isFinite(lastEventId) && lastEventId >= 0 ? lastEventId : undefined;
|
|
||||||
|
|
||||||
// Take full control of the raw response so Fastify doesn't interfere.
|
// Take full control of the raw response so Fastify doesn't interfere.
|
||||||
reply.hijack();
|
reply.hijack();
|
||||||
@ -290,26 +152,17 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const writeEvent = (event: AgentHubEvent) => {
|
const listener = (event: AgentHubEvent) => {
|
||||||
// Server-side role filter: skip tasks that belong to a different role.
|
// Server-side role filter: skip tasks that belong to a different role.
|
||||||
// Handoffs, decisions and memory always pass through.
|
// Handoffs, decisions and memory always pass through.
|
||||||
if (role && event.type === 'task' && event.role !== undefined && event.role !== role) {
|
if (role && event.type === 'task' && event.role !== undefined && event.role !== role) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const idLine = event.seq !== undefined ? `id: ${event.seq}\n` : '';
|
writeSse(`data: ${JSON.stringify(event)}\n\n`);
|
||||||
writeSse(`${idLine}data: ${JSON.stringify(event)}\n\n`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const listener = (event: AgentHubEvent) => writeEvent(event);
|
|
||||||
|
|
||||||
eventBus.on('change', listener);
|
eventBus.on('change', listener);
|
||||||
|
|
||||||
if (replayAfterSeq !== undefined) {
|
|
||||||
for (const event of listHubEventsAfter(cwd, replayAfterSeq)) {
|
|
||||||
writeEvent(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task-log lines ride a NAMED `task-log` SSE event so the board's generic
|
// Task-log lines ride a NAMED `task-log` SSE event so the board's generic
|
||||||
// onmessage handler ignores them; only the task-detail live console listens.
|
// onmessage handler ignores them; only the task-detail live console listens.
|
||||||
const logListener = (payload: unknown) => {
|
const logListener = (payload: unknown) => {
|
||||||
@ -335,7 +188,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
app.post('/announce', async (request, reply) => {
|
app.post('/announce', async (request, reply) => {
|
||||||
const { agent, role, action } = request.body as { agent?: string; role?: string; action?: string };
|
const { agent, role, action } = request.body as { agent?: string; role?: string; action?: string };
|
||||||
if (!agent) return badRequest(reply, 'agent is required');
|
if (!agent) return badRequest(reply, 'agent is required');
|
||||||
stampSeen(agent);
|
|
||||||
const ev: AgentHubEvent = {
|
const ev: AgentHubEvent = {
|
||||||
type: 'agent',
|
type: 'agent',
|
||||||
action: action === 'left' ? 'left' : 'joined',
|
action: action === 'left' ? 'left' : 'joined',
|
||||||
@ -345,24 +197,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
eventBus.publish(ev);
|
eventBus.publish(ev);
|
||||||
return { ok: true, agent, action: ev.action };
|
return { ok: true, agent, action: ev.action };
|
||||||
});
|
});
|
||||||
app.post('/agents/:agent/loop', async (request, reply) => {
|
|
||||||
const agent = (request.params as { agent: string }).agent;
|
|
||||||
const { active, reason } = request.body as { active?: boolean; reason?: string };
|
|
||||||
try {
|
|
||||||
const canonical = agentIdentity(cwd, agent).canonical;
|
|
||||||
if (active) enterLoop(canonical);
|
|
||||||
else leaveLoop(canonical, reason ?? 'ended');
|
|
||||||
eventBus.publish({
|
|
||||||
type: 'agent',
|
|
||||||
action: active ? 'joined' : 'left',
|
|
||||||
id: canonical,
|
|
||||||
status: active ? 'in_loop' : `out_of_loop:${reason ?? 'ended'}`,
|
|
||||||
});
|
|
||||||
return { ok: true, agent: canonical, active: !!active, reason };
|
|
||||||
} catch (err) {
|
|
||||||
return badRequest(reply, err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Status ──────────────────────────────────────────────────────────────
|
// ─── Status ──────────────────────────────────────────────────────────────
|
||||||
app.get('/status', async () => ({ body: getStatus(cwd) }));
|
app.get('/status', async () => ({ body: getStatus(cwd) }));
|
||||||
@ -429,16 +263,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
);
|
);
|
||||||
return task;
|
return task;
|
||||||
});
|
});
|
||||||
app.post('/tasks/record', async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const task = recordExternalTask(cwd, request.body as Parameters<typeof recordExternalTask>[1]);
|
|
||||||
logTaskStatus(task.id, `Nachgetragen: außerhalb von AgentHub erledigt durch ${task.doneBy}`, task.doneBy);
|
|
||||||
emitChange({ type: 'task', action: 'created', id: task.id, title: task.title, status: task.status, role: task.role, assignedTo: task.assignedTo, claimedBy: task.claimedBy }, task.updatedAt);
|
|
||||||
return task;
|
|
||||||
} catch (err) {
|
|
||||||
return badRequest(reply, err instanceof Error ? err.message : 'Could not record external work');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete('/tasks/:id', async (request, reply) => {
|
app.delete('/tasks/:id', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
@ -490,40 +314,8 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return badRequest(reply, err instanceof Error ? err.message : 'Invalid log line');
|
return badRequest(reply, err instanceof Error ? err.message : 'Invalid log line');
|
||||||
}
|
}
|
||||||
if (agent) stampSeen(agent);
|
|
||||||
eventBus.publishLog({ taskId: id, ...entry });
|
eventBus.publishLog({ taskId: id, ...entry });
|
||||||
// TSK-0274 Check-in-Kanal: ein arbeitender Agent ist zwischen zwei
|
return entry;
|
||||||
// work-Aufrufen taub. Dieser Log-Aufruf ist der einzige Moment, in dem er
|
|
||||||
// von sich aus spricht — also geben wir ihm hier zurück, was auf ihn
|
|
||||||
// wartet (Reopen/Cancel seines Tasks, Nachrichten, offene Zuweisungen).
|
|
||||||
if (!agent) return entry;
|
|
||||||
const pending = resolvePending(cwd, agent, id);
|
|
||||||
return hasPending(pending) ? { ...entry, pending } : entry;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expliziter Check-in (TSK-0274) — für Agenten, die zwischendurch nachsehen
|
|
||||||
* wollen, ohne eine Log-Zeile zu schreiben, und für die Health-Sichtbarkeit.
|
|
||||||
* `taskId` optional: damit wird zusätzlich geprüft, ob der Task dem Agenten
|
|
||||||
* überhaupt noch gehört.
|
|
||||||
*/
|
|
||||||
app.get('/agents/:agent/pending', async (request) => {
|
|
||||||
const { agent } = request.params as { agent: string };
|
|
||||||
const { taskId } = request.query as { taskId?: string };
|
|
||||||
stampSeen(agent);
|
|
||||||
return resolvePending(cwd, agent, taskId);
|
|
||||||
});
|
|
||||||
app.post('/tasks/:id/dispatch', async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const { agent } = request.body as { agent?: string };
|
|
||||||
try {
|
|
||||||
const task = dispatchTask(cwd, id, agent ?? '');
|
|
||||||
logTaskStatus(id, `Architect started ${task.claimedBy}`, 'architect');
|
|
||||||
emitChange({ type: 'task', action: 'updated', id, title: task.title, status: task.status, role: task.role, assignedTo: task.assignedTo, claimedBy: task.claimedBy }, task.updatedAt);
|
|
||||||
return task;
|
|
||||||
} catch (err) {
|
|
||||||
return badRequest(reply, err instanceof Error ? err.message : 'Dispatch failed');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch('/tasks/:id', async (request, reply) => {
|
app.patch('/tasks/:id', async (request, reply) => {
|
||||||
@ -533,12 +325,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
// Assign without claiming: address an open task to an agent (no status
|
// Assign without claiming: address an open task to an agent (no status
|
||||||
// change). Fires task/updated so a waiting `agenthub work` auto-claims it.
|
// change). Fires task/updated so a waiting `agenthub work` auto-claims it.
|
||||||
if (patch.assignedTo !== undefined && patch.status === undefined) {
|
if (patch.assignedTo !== undefined && patch.status === undefined) {
|
||||||
let assigned: Task;
|
const assigned = assignTask(cwd, id, patch.assignedTo);
|
||||||
try {
|
|
||||||
assigned = assignTask(cwd, id, patch.assignedTo);
|
|
||||||
} catch (err) {
|
|
||||||
return badRequest(reply, err instanceof Error ? err.message : 'Invalid assignee');
|
|
||||||
}
|
|
||||||
logTaskStatus(id, `Addressed to ${assigned.assignedTo}`, assigned.assignedTo);
|
logTaskStatus(id, `Addressed to ${assigned.assignedTo}`, assigned.assignedTo);
|
||||||
emitChange(
|
emitChange(
|
||||||
{
|
{
|
||||||
@ -558,8 +345,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
let task: Task;
|
let task: Task;
|
||||||
try {
|
switch (patch.status) {
|
||||||
switch (patch.status) {
|
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
{
|
{
|
||||||
const current = getTask(cwd, id).task;
|
const current = getTask(cwd, id).task;
|
||||||
@ -567,8 +353,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
// claimTask is race-guarded (open-only). Surface a lost race / non-open
|
// claimTask is race-guarded (open-only). Surface a lost race / non-open
|
||||||
// claim as a clean 400 so the board drag reverts gracefully instead of
|
// claim as a clean 400 so the board drag reverts gracefully instead of
|
||||||
// 500-ing, and a second agent can't clobber the first's claim.
|
// 500-ing, and a second agent can't clobber the first's claim.
|
||||||
task = claimTask(cwd, id, agent);
|
try {
|
||||||
stampSeen(agent);
|
task = claimTask(cwd, id, agent);
|
||||||
|
} catch (err) {
|
||||||
|
return badRequest(reply, err instanceof Error ? err.message : 'Cannot claim task');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'done':
|
case 'done':
|
||||||
@ -577,12 +366,9 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
doneTokens: patch.doneTokens as number | undefined,
|
doneTokens: patch.doneTokens as number | undefined,
|
||||||
doneDuration: patch.doneDuration as number | undefined,
|
doneDuration: patch.doneDuration as number | undefined,
|
||||||
});
|
});
|
||||||
if (task.doneBy) stampSeen(task.doneBy);
|
|
||||||
break;
|
break;
|
||||||
case 'review':
|
case 'review':
|
||||||
task = reviewTask(cwd, id, patch.reviewer);
|
task = reviewTask(cwd, id, patch.reviewer);
|
||||||
// The submitter (assignee) is the acting agent here.
|
|
||||||
if (task.assignedTo) stampSeen(task.assignedTo);
|
|
||||||
break;
|
break;
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
task = cancelTask(cwd, id);
|
task = cancelTask(cwd, id);
|
||||||
@ -590,13 +376,8 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
case 'open':
|
case 'open':
|
||||||
task = reopenTask(cwd, id);
|
task = reopenTask(cwd, id);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled');
|
return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled');
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Invalid task transition';
|
|
||||||
logTaskStatus(id, `Rejected transition${patch.status ? ` → ${patch.status}` : ''}: ${message}`, patch.assignedTo);
|
|
||||||
return badRequest(reply, message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uniformly log the transition for every status-changing caller (claim /
|
// Uniformly log the transition for every status-changing caller (claim /
|
||||||
@ -706,7 +487,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return badRequest(reply, err instanceof Error ? err.message : 'Invalid message');
|
return badRequest(reply, err instanceof Error ? err.message : 'Invalid message');
|
||||||
}
|
}
|
||||||
stampSeen(message.from);
|
|
||||||
emitChange(
|
emitChange(
|
||||||
{
|
{
|
||||||
type: 'message',
|
type: 'message',
|
||||||
@ -714,7 +494,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
id: message.id,
|
id: message.id,
|
||||||
title: `${message.from} → ${message.to}`,
|
title: `${message.from} → ${message.to}`,
|
||||||
assignedTo: message.to,
|
assignedTo: message.to,
|
||||||
from: message.from,
|
|
||||||
},
|
},
|
||||||
message.updatedAt,
|
message.updatedAt,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -190,7 +190,7 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
|||||||
.log-line { display:flex;gap:8px;align-items:baseline;overflow-wrap:anywhere; }
|
.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-ts { color:var(--muted);white-space:nowrap;flex:0 0 auto; }
|
||||||
.log-agent { color:var(--accent);white-space:nowrap;flex:0 0 auto; }
|
.log-agent { color:var(--accent);white-space:nowrap;flex:0 0 auto; }
|
||||||
.log-text { color:var(--text);flex:1 1 auto;min-width:0; }
|
.log-text { color:var(--text);min-width:0; }
|
||||||
.log-line[data-level="status"] .log-text { color:var(--status-review); }
|
.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="warn"] .log-text { color:var(--status-review); }
|
||||||
.log-line[data-level="error"] .log-text { color:#F85149; }
|
.log-line[data-level="error"] .log-text { color:#F85149; }
|
||||||
|
|||||||
@ -291,7 +291,7 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act
|
|||||||
// identically on the non-board pages.
|
// identically on the non-board pages.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type HeaderPage = 'board' | 'team' | 'activity' | 'messages' | 'decisions' | 'archive' | 'task' | 'health';
|
export type HeaderPage = 'board' | 'team' | 'activity' | 'messages' | 'decisions' | 'archive' | 'task';
|
||||||
|
|
||||||
/** CSS for ONLY the shared header (board v2 reuses this inside boardV2Css). */
|
/** CSS for ONLY the shared header (board v2 reuses this inside boardV2Css). */
|
||||||
export function appHeaderOnlyCss(): string {
|
export function appHeaderOnlyCss(): string {
|
||||||
@ -408,7 +408,6 @@ export function appHeader(projectName: string, current: HeaderPage): string {
|
|||||||
<nav class="b2-nav" aria-label="Primary">
|
<nav class="b2-nav" aria-label="Primary">
|
||||||
${link('Board', '/board', 'board')}
|
${link('Board', '/board', 'board')}
|
||||||
${link('Team', '/team', 'team')}
|
${link('Team', '/team', 'team')}
|
||||||
${link('Health', '/agent-health', 'health')}
|
|
||||||
${link('Activity', '/activity', 'activity')}
|
${link('Activity', '/activity', 'activity')}
|
||||||
${link('Messages', '/messages', 'messages')}
|
${link('Messages', '/messages', 'messages')}
|
||||||
${link('Decisions', '/decisions', 'decisions')}
|
${link('Decisions', '/decisions', 'decisions')}
|
||||||
|
|||||||
@ -1,226 +0,0 @@
|
|||||||
import { loadConfig } from '../core/config.js';
|
|
||||||
import type { Config, WatchdogConfig } from '../core/schema.js';
|
|
||||||
import { listTasks, getTask } from '../core/services/taskService.js';
|
|
||||||
import { readTaskLog, appendTaskLog } from '../core/services/taskLogService.js';
|
|
||||||
import { createMessage } from '../core/services/messageService.js';
|
|
||||||
import { emitChange, eventBus } from './events.js';
|
|
||||||
import { agentLoopStatus } from '../core/services/presenceService.js';
|
|
||||||
import { resolveAgentName } from '../core/services/identityService.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-side stuck-task watchdog (TSK-0226).
|
|
||||||
*
|
|
||||||
* Nobody should have to wake agents manually or spot priority inversions by
|
|
||||||
* hand — the hub re-notifies autonomously:
|
|
||||||
*
|
|
||||||
* (a) OPEN + assigned but unclaimed for > 3 min (high/critical) or > 10 min
|
|
||||||
* (medium/low) ⇒ re-emit the task event (SSE) so a waiting `work` loop
|
|
||||||
* re-wakes, send the assignee an `unread` reminder message (work loops
|
|
||||||
* wake on unread mail), and log a board-visible warn line on the task.
|
|
||||||
* (b) IN_PROGRESS with no task-log line for > 15 min ⇒ alert the architect
|
|
||||||
* (visibility only — reassignment stays an architect decision).
|
|
||||||
*
|
|
||||||
* Anti-spam: a given task is re-alerted only after its threshold has elapsed
|
|
||||||
* again since the last alert (per task + alert kind).
|
|
||||||
*
|
|
||||||
* All thresholds + the interval are configurable via the `watchdog` section of
|
|
||||||
* agenthub.config.json; absent config ⇒ the schema defaults (see schema.ts).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** last alert timestamp per `${taskId}:${kind}` — in-memory, per hub process. */
|
|
||||||
const lastAlertAt = new Map<string, number>();
|
|
||||||
|
|
||||||
function architectName(config: Config): string {
|
|
||||||
return config.roles.architect?.preferredAgent ?? 'claude';
|
|
||||||
}
|
|
||||||
|
|
||||||
function thresholdFor(priority: string | undefined, cfg: WatchdogConfig): number {
|
|
||||||
return priority === 'high' || priority === 'critical' ? cfg.unclaimedHighMs : cfg.unclaimedDefaultMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cooledDown(key: string, threshold: number, now: number): boolean {
|
|
||||||
const last = lastAlertAt.get(key);
|
|
||||||
return last === undefined || now - last >= threshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Board-visible alert: a warn line on the task's live console + SSE fan-out. */
|
|
||||||
function logAlert(cwd: string, id: string, text: string): void {
|
|
||||||
try {
|
|
||||||
const entry = appendTaskLog(cwd, id, { text, agent: 'watchdog', level: 'warn' });
|
|
||||||
eventBus.publishLog({ taskId: id, ...entry });
|
|
||||||
} catch {
|
|
||||||
/* best-effort */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One watchdog scan. Exported for tests; `startWatchdog` drives it on a timer.
|
|
||||||
* Returns the number of alerts raised (for tests/diagnostics).
|
|
||||||
*/
|
|
||||||
export function runWatchdogScan(cwd: string, now: number = Date.now()): number {
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
const cfg = config.watchdog;
|
|
||||||
let alerts = 0;
|
|
||||||
|
|
||||||
for (const t of listTasks(cwd)) {
|
|
||||||
// (a) OPEN + addressed to an agent, but never claimed.
|
|
||||||
if (t.status === 'open' && t.assignedTo && !t.claimedBy) {
|
|
||||||
let priority: string | undefined;
|
|
||||||
let title: string | undefined = t.title;
|
|
||||||
try {
|
|
||||||
const full = getTask(cwd, t.id).task;
|
|
||||||
priority = full.priority;
|
|
||||||
title = full.title;
|
|
||||||
} catch {
|
|
||||||
/* fall back to the index row */
|
|
||||||
}
|
|
||||||
const threshold = thresholdFor(priority, cfg);
|
|
||||||
const waitingMs = now - Date.parse(t.updatedAt || t.createdAt);
|
|
||||||
const key = `${t.id}:unclaimed`;
|
|
||||||
let assignee = t.assignedTo;
|
|
||||||
try { assignee = resolveAgentName(cwd, t.assignedTo); } catch { /* invalid legacy target is reported below */ }
|
|
||||||
const dispatch = config.agents?.[assignee]?.dispatch ?? 'loop';
|
|
||||||
const shouldAlert = dispatch === 'architect' ? !lastAlertAt.has(key) : cooledDown(key, threshold, now);
|
|
||||||
if (waitingMs >= threshold && shouldAlert) {
|
|
||||||
lastAlertAt.set(key, now);
|
|
||||||
alerts++;
|
|
||||||
if (dispatch === 'architect') {
|
|
||||||
const architect = architectName(config);
|
|
||||||
try {
|
|
||||||
createMessage(cwd, {
|
|
||||||
from: 'agenthub',
|
|
||||||
to: architect,
|
|
||||||
text: `${t.id} wartet auf ${assignee} — der wird von dir gestartet, nicht von selbst.`,
|
|
||||||
taskId: t.id,
|
|
||||||
});
|
|
||||||
} catch { /* best-effort */ }
|
|
||||||
logAlert(cwd, t.id, `Watchdog: wartet auf Architekten-Start von ${assignee}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (agentLoopStatus(assignee) === 'inactive') {
|
|
||||||
const architect = architectName(config);
|
|
||||||
try {
|
|
||||||
createMessage(cwd, {
|
|
||||||
from: 'agenthub',
|
|
||||||
to: architect,
|
|
||||||
text: `Watchdog: ${t.id} is assigned to ${assignee}, but that agent is not in agenthub_work — manual/session wake may be required.`,
|
|
||||||
taskId: t.id,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
/* best-effort */
|
|
||||||
}
|
|
||||||
logAlert(cwd, t.id, `Watchdog: ${assignee} is out of the work loop — alerted ${architect}`);
|
|
||||||
}
|
|
||||||
// 1. Re-emit the task event so SSE subscribers / work loops re-wake.
|
|
||||||
emitChange(
|
|
||||||
{
|
|
||||||
type: 'task',
|
|
||||||
action: 'updated',
|
|
||||||
id: t.id,
|
|
||||||
title,
|
|
||||||
status: t.status,
|
|
||||||
role: t.role,
|
|
||||||
assignedTo: t.assignedTo,
|
|
||||||
claimedBy: t.claimedBy,
|
|
||||||
reviewer: t.reviewer,
|
|
||||||
},
|
|
||||||
t.updatedAt,
|
|
||||||
);
|
|
||||||
// 2. Unread reminder message — work loops wake on unread mail.
|
|
||||||
try {
|
|
||||||
createMessage(cwd, {
|
|
||||||
from: 'agenthub',
|
|
||||||
to: t.assignedTo,
|
|
||||||
text: `Reminder: ${t.id} wartet auf dich`,
|
|
||||||
taskId: t.id,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
/* best-effort */
|
|
||||||
}
|
|
||||||
// 3. Board-visible alert.
|
|
||||||
logAlert(cwd, t.id, `Watchdog: unclaimed for ${Math.round(waitingMs / 60_000)}m — re-notified ${t.assignedTo}`);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// (b) IN_PROGRESS but silent — no task-log line for too long.
|
|
||||||
if (t.status === 'in_progress') {
|
|
||||||
const log = readTaskLog(cwd, t.id, 1);
|
|
||||||
const lastActivity = log.length > 0 ? Date.parse(log[log.length - 1].ts) : Date.parse(t.updatedAt || t.createdAt);
|
|
||||||
const silentMs = now - (isNaN(lastActivity) ? now : lastActivity);
|
|
||||||
const key = `${t.id}:stale`;
|
|
||||||
if (silentMs >= cfg.staleInProgressMs && cooledDown(key, cfg.staleInProgressMs, now)) {
|
|
||||||
lastAlertAt.set(key, now);
|
|
||||||
alerts++;
|
|
||||||
const architect = architectName(config);
|
|
||||||
try {
|
|
||||||
createMessage(cwd, {
|
|
||||||
from: 'agenthub',
|
|
||||||
to: architect,
|
|
||||||
text: `Watchdog: ${t.id} is in_progress by ${t.claimedBy ?? t.assignedTo ?? '?'} but silent for ${Math.round(silentMs / 60_000)}m — no auto-reassign, your call.`,
|
|
||||||
taskId: t.id,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
/* best-effort */
|
|
||||||
}
|
|
||||||
logAlert(cwd, t.id, `Watchdog: silent for ${Math.round(silentMs / 60_000)}m — alerted ${architect}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t.status === 'review') {
|
|
||||||
const waitingMs = now - Date.parse(t.updatedAt || t.createdAt);
|
|
||||||
const key = `${t.id}:review`;
|
|
||||||
if (waitingMs >= cfg.staleReviewMs && cooledDown(key, cfg.staleReviewMs, now)) {
|
|
||||||
lastAlertAt.set(key, now);
|
|
||||||
alerts++;
|
|
||||||
const architect = architectName(config);
|
|
||||||
try {
|
|
||||||
createMessage(cwd, {
|
|
||||||
from: 'agenthub',
|
|
||||||
to: architect,
|
|
||||||
text: `Watchdog: ${t.id} has waited in review for ${Math.round(waitingMs / 60_000)}m — approve or reopen it.`,
|
|
||||||
taskId: t.id,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
/* best-effort */
|
|
||||||
}
|
|
||||||
logAlert(cwd, t.id, `Watchdog: review waiting ${Math.round(waitingMs / 60_000)}m — alerted ${architect}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return alerts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the watchdog loop. Returns a stop function (clears the timer).
|
|
||||||
* The interval is unref'd so it can never keep a process alive on its own.
|
|
||||||
*/
|
|
||||||
export function startWatchdog(cwd: string): () => void {
|
|
||||||
let timer: NodeJS.Timeout | undefined;
|
|
||||||
let cfg: WatchdogConfig;
|
|
||||||
try {
|
|
||||||
cfg = loadConfig(cwd).watchdog;
|
|
||||||
} catch {
|
|
||||||
return () => { /* no config, no watchdog */ };
|
|
||||||
}
|
|
||||||
if (!cfg.enabled) return () => { /* disabled via config */ };
|
|
||||||
|
|
||||||
timer = setInterval(() => {
|
|
||||||
try {
|
|
||||||
runWatchdogScan(cwd);
|
|
||||||
} catch {
|
|
||||||
/* the watchdog must never crash the server */
|
|
||||||
}
|
|
||||||
}, cfg.intervalMs);
|
|
||||||
timer.unref?.();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timer) clearInterval(timer);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Test hook: drop all cooldown state. */
|
|
||||||
export function resetWatchdog(): void {
|
|
||||||
lastAlertAt.clear();
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
/** Single source of truth for the agenthub version (package.json, CLI, MCP, /health). */
|
|
||||||
export const VERSION = '0.10.2';
|
|
||||||
@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|||||||
import { mkdtempSync, rmSync } from 'fs';
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { createTask, claimTask, reviewTask, doneTask } from '../src/core/services/taskService.js';
|
import { createTask, claimTask, doneTask } from '../src/core/services/taskService.js';
|
||||||
import { createHandoff } from '../src/core/services/handoffService.js';
|
import { createHandoff } from '../src/core/services/handoffService.js';
|
||||||
import { addMemory } from '../src/core/services/memoryService.js';
|
import { addMemory } from '../src/core/services/memoryService.js';
|
||||||
import { getTaskActivity } from '../src/core/services/activityService.js';
|
import { getTaskActivity } from '../src/core/services/activityService.js';
|
||||||
@ -41,8 +41,6 @@ describe('activityService', () => {
|
|||||||
|
|
||||||
it('includes a status event with tokens/duration when task is done with metadata', () => {
|
it('includes a status event with tokens/duration when task is done with metadata', () => {
|
||||||
const task = createTask(cwd, { title: 'Finish me', role: 'implementer' });
|
const task = createTask(cwd, { title: 'Finish me', role: 'implementer' });
|
||||||
claimTask(cwd, task.id, 'codex');
|
|
||||||
reviewTask(cwd, task.id);
|
|
||||||
doneTask(cwd, task.id, { doneBy: 'claude', doneTokens: 8500, doneDuration: 120_000 });
|
doneTask(cwd, task.id, { doneBy: 'claude', doneTokens: 8500, doneDuration: 120_000 });
|
||||||
const items = getTaskActivity(cwd, task.id);
|
const items = getTaskActivity(cwd, task.id);
|
||||||
const status = items.find((i) => i.kind === 'status');
|
const status = items.find((i) => i.kind === 'status');
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { codexHookBlock } from '../src/cli/commands/agentSetup.js';
|
|
||||||
|
|
||||||
describe('Codex AgentHub lifecycle hooks', () => {
|
|
||||||
it('installs a host-native Stop-hook worker in addition to SessionStart context', () => {
|
|
||||||
const block = codexHookBlock('codex', 'implementer');
|
|
||||||
|
|
||||||
expect(block).toContain('[[hooks.SessionStart]]');
|
|
||||||
expect(block).toContain('agenthub hook-context --agent codex');
|
|
||||||
expect(block).toContain('[[hooks.Stop]]');
|
|
||||||
expect(block).toContain('agenthub hook-stop --agent codex');
|
|
||||||
expect(block).not.toContain('--role implementer');
|
|
||||||
expect(block).toContain('timeout = 86400');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression tests for the agent health-check page (TSK-0230, HOF-0085).
|
|
||||||
* - GET /agent-health serves the page with the per-agent traffic light
|
|
||||||
* (same agentLight data as /health — reused, not duplicated).
|
|
||||||
* - The test-message wiring + cross-messaging data ship with the page.
|
|
||||||
* - The page surfaces the transport mode (SSE vs polling fallback).
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } 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';
|
|
||||||
|
|
||||||
describe('GET /agent-health (TSK-0230)', () => {
|
|
||||||
let cwd: string;
|
|
||||||
let app: ReturnType<typeof buildApp>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-agent-health-'));
|
|
||||||
init(cwd, { projectName: 'health-test', yes: true });
|
|
||||||
app = buildApp(cwd);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('serves the health-check page as HTML with nav + roster agents', async () => {
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/agent-health' });
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.headers['content-type']).toContain('text/html');
|
|
||||||
|
|
||||||
const html = res.payload;
|
|
||||||
expect(html).toContain('<title>AgentHub Agent Health</title>');
|
|
||||||
expect(html).toContain('health-test');
|
|
||||||
// Nav link from the shared header.
|
|
||||||
expect(html).toContain('/agent-health');
|
|
||||||
// Roster agents get a card with a traffic light each.
|
|
||||||
expect(html).toContain('data-agent="claude"');
|
|
||||||
expect(html).toContain('data-agent="codex"');
|
|
||||||
expect(html).toContain('data-light');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks an unclaimed assigned agent as stale (TSK-0226 ampel, reused)', async () => {
|
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'waiting work', role: 'implementer' } });
|
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { assignedTo: 'codex' } });
|
|
||||||
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/agent-health' });
|
|
||||||
const html = res.payload;
|
|
||||||
// codex was never seen + has an open assignment ⇒ stale (red), per agentLight.
|
|
||||||
expect(html).toContain('light-stale');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ships the test-message send + delivery-tracking wiring', async () => {
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/agent-health' });
|
|
||||||
const html = res.payload;
|
|
||||||
expect(html).toContain('ac-send');
|
|
||||||
expect(html).toContain('Send test');
|
|
||||||
expect(html).toContain('data-track');
|
|
||||||
// Delivery tracking observes the side-effect-free full list (never the
|
|
||||||
// ?agent= inbox variant, whose read receipt would fake delivery).
|
|
||||||
expect(html).toContain("fetch('/messages'");
|
|
||||||
expect(html).not.toContain("fetch('/messages?agent=");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('embeds cross-messaging data (direction, status) for the per-agent view', async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/messages',
|
|
||||||
payload: { from: 'claude', to: 'codex', text: 'cross-wire check' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/agent-health' });
|
|
||||||
const html = res.payload;
|
|
||||||
expect(html).toContain('cross-wire check');
|
|
||||||
expect(html).toContain('__HEALTH_DATA__');
|
|
||||||
expect(html).toContain('cross-messaging');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces the transport mode badge (SSE live vs polling fallback)', async () => {
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/agent-health' });
|
|
||||||
const html = res.payload;
|
|
||||||
expect(html).toContain('id="transport"');
|
|
||||||
expect(html).toContain('polling fallback');
|
|
||||||
expect(html).toContain('SSE live');
|
|
||||||
// The page consumes the durable event seq (TSK-0225) via EventSource.
|
|
||||||
expect(html).toContain("new EventSource('/events')");
|
|
||||||
expect(html).toContain('lastEventId');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the /health JSON endpoint intact alongside the page', async () => {
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/health' });
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const json = res.json() as { status: string; agents: Array<{ name: string; state: string }> };
|
|
||||||
expect(json.status).toBe('ok');
|
|
||||||
expect(json.agents.map((a) => a.name)).toContain('claude');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { mkdtempSync, rmSync } from 'fs';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { init } from '../src/cli/commands/init.js';
|
|
||||||
import { createTask, claimTask, reviewTask, doneTask } from '../src/core/services/taskService.js';
|
|
||||||
import { createHandoff } from '../src/core/services/handoffService.js';
|
|
||||||
import { createMessage } from '../src/core/services/messageService.js';
|
|
||||||
import { computeBudget, TOKENS_PER_COORDINATION_ACTION } from '../src/core/services/budgetService.js';
|
|
||||||
|
|
||||||
/** Architect coordination work must be visible in the budget report (TSK-0226). */
|
|
||||||
describe('budget: architect activity', () => {
|
|
||||||
let cwd: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-budget-'));
|
|
||||||
init(cwd, { projectName: 'budget-test', yes: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('counts reviews, handoffs and messages as architect activity', () => {
|
|
||||||
// Implementer-owned task; the architect only coordinates around it.
|
|
||||||
createTask(cwd, { title: 'Feature', role: 'implementer', assignedTo: 'codex' });
|
|
||||||
claimTask(cwd, 'TSK-0001', 'codex');
|
|
||||||
reviewTask(cwd, 'TSK-0001', 'claude');
|
|
||||||
createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', fromAgent: 'claude', toAgent: 'codex', taskId: 'TSK-0001', summary: 'scope' });
|
|
||||||
createMessage(cwd, { from: 'claude', to: 'codex', text: 'please review' });
|
|
||||||
|
|
||||||
const report = computeBudget(cwd);
|
|
||||||
const claude = report.agents.find((a) => a.name === 'claude');
|
|
||||||
expect(claude).toBeDefined();
|
|
||||||
// reviewer + handoff + message = 3 actions, all estimated (never silent fabrication).
|
|
||||||
expect(claude!.actions).toBe(3);
|
|
||||||
expect(claude!.tokens).toBe(3 * TOKENS_PER_COORDINATION_ACTION);
|
|
||||||
expect(claude!.estimated).toBe(true);
|
|
||||||
expect(claude!.estimatedTokens).toBe(claude!.tokens);
|
|
||||||
expect(claude!.costEur).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a done approval by someone other than the owner counts as an action', () => {
|
|
||||||
createTask(cwd, { title: 'Feature', role: 'implementer', assignedTo: 'codex' });
|
|
||||||
claimTask(cwd, 'TSK-0001', 'codex');
|
|
||||||
reviewTask(cwd, 'TSK-0001', 'claude');
|
|
||||||
// Architect approves: doneBy = claude, owner = codex.
|
|
||||||
doneTask(cwd, 'TSK-0001', { doneBy: 'claude', doneTokens: 8000 });
|
|
||||||
|
|
||||||
const report = computeBudget(cwd);
|
|
||||||
const claude = report.agents.find((a) => a.name === 'claude')!;
|
|
||||||
// review + approval; the implementer keeps the real task tokens.
|
|
||||||
expect(claude.actions).toBe(2);
|
|
||||||
const codex = report.agents.find((a) => a.name === 'codex')!;
|
|
||||||
expect(codex.realTokens).toBe(8000);
|
|
||||||
// No double count: claude's action estimate does not include the 8000.
|
|
||||||
expect(claude.tokens).toBe(2 * TOKENS_PER_COORDINATION_ACTION);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,230 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression tests for TSK-0230 (HOF-0086): realtime auto-wake / auto-claim.
|
|
||||||
*
|
|
||||||
* An agent sitting in the work-wait — CLI `agenthub work` AND the MCP
|
|
||||||
* `agenthub_work` path (waitForTask) — must wake with NO manual poke when
|
|
||||||
* (a) the architect assigns an existing open task to it (task_assign), or
|
|
||||||
* (b) a message arrives for it,
|
|
||||||
* over the SSE stream AND over the polling fallback alone (SSE down).
|
|
||||||
* With default settings the auto-claim must land within ≤10s.
|
|
||||||
*
|
|
||||||
* Background: TSK-0230 was assigned + messaged to kimi-ah and the waiting
|
|
||||||
* loop did not claim it — the CEO had to poke the agent manually. These
|
|
||||||
* tests pin the wake path so that class of failure cannot regress.
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { mkdtempSync, rmSync } from 'fs';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { init } from '../src/cli/commands/init.js';
|
|
||||||
import { workAgent } from '../src/cli/commands/work.js';
|
|
||||||
import { findAddressedOpenTask, type AgentContext } from '../src/cli/commands/start.js';
|
|
||||||
import { remoteClient } from '../src/cli/remoteClient.js';
|
|
||||||
import { waitForTask } from '../src/mcp/server.js';
|
|
||||||
import { startServer } from '../src/server/index.js';
|
|
||||||
import type { Task } from '../src/core/schema.js';
|
|
||||||
|
|
||||||
const AGENT = 'kimi';
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((r) => setTimeout(r, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create an open implementer task that is NOT addressed to AGENT. */
|
|
||||||
async function createUnaddressedTask(serverUrl: string): Promise<string> {
|
|
||||||
const res = await fetch(`${serverUrl}/tasks`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ title: 'someone-else: not for kimi', role: 'implementer' }),
|
|
||||||
});
|
|
||||||
const task = (await res.json()) as Task;
|
|
||||||
return task.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The architect's task_assign: address the open task to AGENT (no claim). */
|
|
||||||
async function assignTask(serverUrl: string, id: string): Promise<void> {
|
|
||||||
await fetch(`${serverUrl}/tasks/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ assignedTo: AGENT }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postMessage(serverUrl: string, text: string): Promise<void> {
|
|
||||||
await fetch(`${serverUrl}/messages`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ from: 'claude', to: AGENT, text }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getTaskViaApi(serverUrl: string, id: string): Promise<Task> {
|
|
||||||
// GET /tasks/:id returns { task, body }, not a flat Task.
|
|
||||||
const res = (await fetch(`${serverUrl}/tasks/${id}`).then((r) => r.json())) as { task: Task };
|
|
||||||
return res.task;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulate "SSE down, REST fine": requests to /events hang open without ever
|
|
||||||
* delivering a frame (the nasty case — a dropped event on an otherwise-open
|
|
||||||
* stream), everything else passes through to the real fetch. The wait may
|
|
||||||
* then only wake via the polling fallback (TSK-0224).
|
|
||||||
*/
|
|
||||||
function breakSseOnly(): void {
|
|
||||||
const realFetch = globalThis.fetch;
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
||||||
if (new URL(url).pathname === '/events') {
|
|
||||||
return new Promise<Response>((_resolve, reject) => {
|
|
||||||
init?.signal?.addEventListener('abort', () =>
|
|
||||||
reject(new DOMException('The operation was aborted.', 'AbortError')),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return realFetch(input, init);
|
|
||||||
}) as typeof fetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('auto-wake on task_assign / message (TSK-0230)', () => {
|
|
||||||
let cwd: string;
|
|
||||||
let server: Awaited<ReturnType<typeof startServer>>;
|
|
||||||
const realFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-autowake-'));
|
|
||||||
init(cwd, { projectName: 'autowake-test', yes: true });
|
|
||||||
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
globalThis.fetch = realFetch;
|
|
||||||
await server.app.close().catch(() => undefined);
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── CLI work loop ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
it('CLI: auto-claims an assigned task via SSE (no poke)', async () => {
|
|
||||||
const taskId = await createUnaddressedTask(server.url);
|
|
||||||
const workDone = workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 8 });
|
|
||||||
await sleep(200);
|
|
||||||
|
|
||||||
const t0 = Date.now();
|
|
||||||
await assignTask(server.url, taskId);
|
|
||||||
await workDone;
|
|
||||||
// SSE is live: the wake must be near-instant, far below the 10s bound.
|
|
||||||
expect(Date.now() - t0).toBeLessThan(2_000);
|
|
||||||
|
|
||||||
const task = await getTaskViaApi(server.url, taskId);
|
|
||||||
expect(task.status).toBe('in_progress');
|
|
||||||
expect(task.assignedTo).toBe(AGENT);
|
|
||||||
}, 12_000);
|
|
||||||
|
|
||||||
it('CLI: auto-claims an assigned task within ≤10s with SSE DOWN (polling fallback only, default interval)', async () => {
|
|
||||||
breakSseOnly();
|
|
||||||
const taskId = await createUnaddressedTask(server.url);
|
|
||||||
// Default pollIntervalMs (4000) on purpose: this is the ≤10s acceptance proof.
|
|
||||||
const workDone = workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 10 });
|
|
||||||
await sleep(300);
|
|
||||||
|
|
||||||
const t0 = Date.now();
|
|
||||||
await assignTask(server.url, taskId);
|
|
||||||
await workDone;
|
|
||||||
expect(Date.now() - t0).toBeLessThan(10_000);
|
|
||||||
|
|
||||||
const task = await getTaskViaApi(server.url, taskId);
|
|
||||||
expect(task.status).toBe('in_progress');
|
|
||||||
expect(task.assignedTo).toBe(AGENT);
|
|
||||||
}, 15_000);
|
|
||||||
|
|
||||||
it('CLI: wakes on an incoming message with SSE DOWN (delivered, not read)', async () => {
|
|
||||||
breakSseOnly();
|
|
||||||
const workDone = workAgent({
|
|
||||||
serverUrl: server.url,
|
|
||||||
projectCwd: cwd,
|
|
||||||
agent: AGENT,
|
|
||||||
role: 'implementer',
|
|
||||||
timeoutSec: 8,
|
|
||||||
pollIntervalMs: 400,
|
|
||||||
});
|
|
||||||
await sleep(300);
|
|
||||||
|
|
||||||
await postMessage(server.url, 'wake without poke (sse down)');
|
|
||||||
await workDone; // resolves because the message woke the loop, not on timeout
|
|
||||||
|
|
||||||
const inbox = (await fetch(`${server.url}/messages?agent=${AGENT}`).then((r) => r.json())) as Array<{
|
|
||||||
text: string;
|
|
||||||
status: string;
|
|
||||||
}>;
|
|
||||||
const m = inbox.find((x) => x.text.includes('wake without poke'));
|
|
||||||
expect(m).toBeDefined();
|
|
||||||
// Surfaced (unread → delivered) but NOT auto-read — a timeout would have left it unread.
|
|
||||||
expect(m?.status).toBe('delivered');
|
|
||||||
}, 12_000);
|
|
||||||
|
|
||||||
// ── MCP agenthub_work path (waitForTask) ──────────────────────────────────
|
|
||||||
|
|
||||||
/** Mimics the agenthub_work finder: addressed-open-task lookup + claim. */
|
|
||||||
function mcpWorkFinder(ctx: AgentContext) {
|
|
||||||
return async (url: string) => {
|
|
||||||
ctx.serverUrl = url;
|
|
||||||
const found = await findAddressedOpenTask(ctx);
|
|
||||||
if (!found) return null;
|
|
||||||
await remoteClient.claimTask(url, found.task.id, ctx.agent);
|
|
||||||
return found.task;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it('MCP: waitForTask auto-claims an assigned task via SSE (no poke)', async () => {
|
|
||||||
const ctx: AgentContext = { serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer' };
|
|
||||||
const taskId = await createUnaddressedTask(server.url);
|
|
||||||
const waiting = waitForTask(server.url, mcpWorkFinder(ctx), 8);
|
|
||||||
await sleep(200);
|
|
||||||
|
|
||||||
const t0 = Date.now();
|
|
||||||
await assignTask(server.url, taskId);
|
|
||||||
const claimed = await waiting;
|
|
||||||
// SSE is live: the wake must be near-instant, far below the 10s bound.
|
|
||||||
expect(Date.now() - t0).toBeLessThan(2_000);
|
|
||||||
|
|
||||||
expect(claimed?.id).toBe(taskId);
|
|
||||||
const task = await getTaskViaApi(server.url, taskId);
|
|
||||||
expect(task.status).toBe('in_progress');
|
|
||||||
expect(task.assignedTo).toBe(AGENT);
|
|
||||||
}, 12_000);
|
|
||||||
|
|
||||||
it('MCP: waitForTask auto-claims an assigned task within ≤10s with SSE DOWN (polling fallback only)', async () => {
|
|
||||||
breakSseOnly();
|
|
||||||
const ctx: AgentContext = { serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer' };
|
|
||||||
const taskId = await createUnaddressedTask(server.url);
|
|
||||||
const waiting = waitForTask(server.url, mcpWorkFinder(ctx), 10);
|
|
||||||
await sleep(300);
|
|
||||||
|
|
||||||
const t0 = Date.now();
|
|
||||||
await assignTask(server.url, taskId);
|
|
||||||
const claimed = await waiting;
|
|
||||||
expect(Date.now() - t0).toBeLessThan(10_000);
|
|
||||||
|
|
||||||
expect(claimed?.id).toBe(taskId);
|
|
||||||
const task = await getTaskViaApi(server.url, taskId);
|
|
||||||
expect(task.status).toBe('in_progress');
|
|
||||||
}, 15_000);
|
|
||||||
|
|
||||||
it('MCP: waitForTask wakes on an incoming message with SSE DOWN', async () => {
|
|
||||||
breakSseOnly();
|
|
||||||
const finder = async (url: string) => {
|
|
||||||
const msgs = await remoteClient.getInbox(url, AGENT, true);
|
|
||||||
return msgs.length ? { messages: msgs } : null;
|
|
||||||
};
|
|
||||||
const waiting = waitForTask(server.url, finder, 8);
|
|
||||||
await sleep(300);
|
|
||||||
|
|
||||||
await postMessage(server.url, 'mcp message wake (sse down)');
|
|
||||||
const hit = await waiting;
|
|
||||||
|
|
||||||
expect(hit).not.toBeNull();
|
|
||||||
expect(hit?.messages[0]?.text).toContain('mcp message wake');
|
|
||||||
// listInbox read receipt: surfaced as delivered, never auto-read.
|
|
||||||
expect(hit?.messages[0]?.status).toBe('delivered');
|
|
||||||
}, 12_000);
|
|
||||||
});
|
|
||||||
@ -27,7 +27,4 @@ describe('kpiJs', () => {
|
|||||||
expect(js).toContain('DAY_MS');
|
expect(js).toContain('DAY_MS');
|
||||||
expect(js).toContain('function dayStart');
|
expect(js).toContain('function dayStart');
|
||||||
});
|
});
|
||||||
it('keeps chips single-line friendly (no wide text prefix)', () => {
|
|
||||||
expect(kpiJs()).not.toContain('prüft ');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,9 +13,4 @@ describe('boardV2Css', () => {
|
|||||||
it('respects prefers-reduced-motion', () => {
|
it('respects prefers-reduced-motion', () => {
|
||||||
expect(boardV2Css()).toContain('@media (prefers-reduced-motion: reduce)');
|
expect(boardV2Css()).toContain('@media (prefers-reduced-motion: reduce)');
|
||||||
});
|
});
|
||||||
it('keeps kpi chips on a single line', () => {
|
|
||||||
const css = boardV2Css();
|
|
||||||
expect(css).toMatch(/\.b2-chips \{[^}]*flex-wrap: nowrap/);
|
|
||||||
expect(css).toMatch(/\.b2-chips \{[^}]*overflow: hidden/);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,211 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { createTask, claimTask, reviewTask, reopenTask, cancelTask, assignTask } from '../src/core/services/taskService.js';
|
|
||||||
import { createMessage, markMessageDelivered } from '../src/core/services/messageService.js';
|
|
||||||
import { resolvePending, hasPending } from '../src/core/services/checkinService.js';
|
|
||||||
import { createAsk, answerAsk } from '../src/core/services/askService.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TSK-0274 — der Check-in-Kanal.
|
|
||||||
*
|
|
||||||
* Kern der Sache: ein Agent, der einen Task AUSFÜHRT, empfängt keine Events.
|
|
||||||
* Diese Tests sichern den einzigen Weg ab, auf dem er trotzdem erfährt, dass
|
|
||||||
* sich etwas an SEINER Arbeit geändert hat.
|
|
||||||
*/
|
|
||||||
|
|
||||||
let cwd: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'agenthub-checkin-'));
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
function openTaskFor(agent: string, title = 'test task') {
|
|
||||||
const task = createTask(cwd, { title: `${agent}: ${title}`, role: 'implementer' });
|
|
||||||
assignTask(cwd, task.id, agent);
|
|
||||||
return task.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('resolvePending — Reopen unter laufender Arbeit', () => {
|
|
||||||
it('meldet interrupted, wenn der gehaltene Task zurückgegeben wurde', () => {
|
|
||||||
const id = openTaskFor('codex');
|
|
||||||
claimTask(cwd, id, 'codex');
|
|
||||||
reviewTask(cwd, id);
|
|
||||||
reopenTask(cwd, id);
|
|
||||||
|
|
||||||
const pending = resolvePending(cwd, 'codex', id);
|
|
||||||
|
|
||||||
expect(pending.interrupted).toBeDefined();
|
|
||||||
expect(pending.interrupted?.taskId).toBe(id);
|
|
||||||
expect(pending.interrupted?.status).toBe('open');
|
|
||||||
// Die Handlungsanweisung muss unmissverständlich sein — der Agent soll
|
|
||||||
// NICHT weiterbauen und NICHT einreichen.
|
|
||||||
expect(pending.interrupted?.action).toMatch(/STOPP/);
|
|
||||||
expect(hasPending(pending)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('meldet interrupted, wenn der Architekt eine laufende Arbeit entzieht (in_progress → open)', () => {
|
|
||||||
const id = openTaskFor('codex');
|
|
||||||
claimTask(cwd, id, 'codex');
|
|
||||||
|
|
||||||
reopenTask(cwd, id); // Entzug ohne Umweg über review
|
|
||||||
|
|
||||||
const pending = resolvePending(cwd, 'codex', id);
|
|
||||||
expect(pending.interrupted?.taskId).toBe(id);
|
|
||||||
expect(pending.interrupted?.status).toBe('open');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('meldet interrupted bei Abbruch', () => {
|
|
||||||
const id = openTaskFor('codex');
|
|
||||||
claimTask(cwd, id, 'codex');
|
|
||||||
cancelTask(cwd, id);
|
|
||||||
|
|
||||||
const pending = resolvePending(cwd, 'codex', id);
|
|
||||||
expect(pending.interrupted?.status).toBe('cancelled');
|
|
||||||
expect(pending.interrupted?.reason).toMatch(/abgebrochen/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('meldet KEIN interrupted, solange der Task normal läuft', () => {
|
|
||||||
const id = openTaskFor('codex');
|
|
||||||
claimTask(cwd, id, 'codex');
|
|
||||||
|
|
||||||
const pending = resolvePending(cwd, 'codex', id);
|
|
||||||
expect(pending.interrupted).toBeUndefined();
|
|
||||||
expect(hasPending(pending)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('verwechselt Agenten nicht — fremder Claim gilt als interrupted', () => {
|
|
||||||
const id = openTaskFor('kimi');
|
|
||||||
claimTask(cwd, id, 'kimi');
|
|
||||||
|
|
||||||
// codex glaubt, an diesem Task zu arbeiten — tut er aber nicht.
|
|
||||||
const pending = resolvePending(cwd, 'codex', id);
|
|
||||||
expect(pending.interrupted?.taskId).toBe(id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolvePending — Nachrichten und Zuweisungen', () => {
|
|
||||||
it('liefert ungelesene Nachrichten mit Vorschau', () => {
|
|
||||||
createMessage(cwd, { from: 'claude', to: 'codex', text: 'Bitte Reihenfolge umdrehen.' });
|
|
||||||
|
|
||||||
const pending = resolvePending(cwd, 'codex');
|
|
||||||
expect(pending.unreadCount).toBe(1);
|
|
||||||
expect(pending.messages[0]?.from).toBe('claude');
|
|
||||||
expect(pending.messages[0]?.preview).toContain('Reihenfolge');
|
|
||||||
expect(pending.note).toMatch(/unbeantwortete/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('nennt offene, an den Agenten adressierte Tasks', () => {
|
|
||||||
const id = openTaskFor('codex');
|
|
||||||
const pending = resolvePending(cwd, 'codex');
|
|
||||||
expect(pending.waitingTasks).toContain(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('nennt eingereichte Tasks, die auf das Review warten', () => {
|
|
||||||
const id = openTaskFor('codex');
|
|
||||||
claimTask(cwd, id, 'codex');
|
|
||||||
reviewTask(cwd, id);
|
|
||||||
|
|
||||||
const pending = resolvePending(cwd, 'codex');
|
|
||||||
expect(pending.awaitingReview).toContain(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bleibt klein, wenn nichts anliegt', () => {
|
|
||||||
const pending = resolvePending(cwd, 'codex');
|
|
||||||
expect(hasPending(pending)).toBe(false);
|
|
||||||
expect(pending.note).toBeUndefined();
|
|
||||||
expect(JSON.stringify(pending).length).toBeLessThan(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('begrenzt die Nachrichten-Vorschau, damit die Antwort klein bleibt', () => {
|
|
||||||
for (let i = 0; i < 12; i += 1) {
|
|
||||||
createMessage(cwd, { from: 'claude', to: 'codex', text: `Nachricht ${i} `.repeat(40) });
|
|
||||||
}
|
|
||||||
const pending = resolvePending(cwd, 'codex');
|
|
||||||
expect(pending.unreadCount).toBe(12);
|
|
||||||
expect(pending.messages.length).toBeLessThanOrEqual(3);
|
|
||||||
// Die Antwort fließt in JEDEN Log-Aufruf — sie darf nicht ausufern.
|
|
||||||
expect(JSON.stringify(pending).length).toBeLessThan(1500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolvePending — beantwortete Fragen (Ask-Timeout-Lücke)', () => {
|
|
||||||
it('liefert die Antwort auf eine eigene Frage nach — auch lange nach dem 300s-Wait', () => {
|
|
||||||
const ask = createAsk(cwd, { from: 'kimi', to: 'claude', question: 'Welcher Weg?' });
|
|
||||||
answerAsk(cwd, ask.id, 'Nimm Variante B.', 'claude');
|
|
||||||
|
|
||||||
const pending = resolvePending(cwd, 'kimi');
|
|
||||||
|
|
||||||
// agenthub_ask(wait) gibt nach 300s auf; braucht der Architekt laenger,
|
|
||||||
// erreicht die Antwort den dormanten Agenten sonst NIE.
|
|
||||||
expect(pending.answeredAsks.map((a) => a.id)).toContain(ask.id);
|
|
||||||
expect(pending.answeredAsks[0]?.answer).toContain('Variante B');
|
|
||||||
expect(pending.note).toMatch(/BEANTWORTET/);
|
|
||||||
expect(hasPending(pending)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('zeigt fremde Antworten nicht an', () => {
|
|
||||||
const ask = createAsk(cwd, { from: 'codex', to: 'claude', question: 'X?' });
|
|
||||||
answerAsk(cwd, ask.id, 'Y', 'claude');
|
|
||||||
expect(resolvePending(cwd, 'kimi').answeredAsks).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolvePending — `delivered` zaehlt weiter', () => {
|
|
||||||
it('zeigt eine bereits ausgelieferte, aber unbeantwortete Nachricht weiter an', () => {
|
|
||||||
const msg = createMessage(cwd, { from: 'claude', to: 'kimi', text: 'Korrektur: nimm doch Variante A.' });
|
|
||||||
markMessageDelivered(cwd, msg.id);
|
|
||||||
|
|
||||||
const pending = resolvePending(cwd, 'kimi');
|
|
||||||
|
|
||||||
// `delivered` heisst nur "einmal aufgetaucht". Filterte der Check-in auf
|
|
||||||
// `unread`, ginge eine Architekten-Korrektur still verloren.
|
|
||||||
expect(pending.unreadCount).toBe(1);
|
|
||||||
expect(hasPending(pending)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolvePending — Adressierung wie im Work-Loop', () => {
|
|
||||||
it('erkennt eine Task am Titel-Praefix, auch ohne assignedTo', () => {
|
|
||||||
// Genau der Fall, der den Session-Start luegen liess: Task traegt das
|
|
||||||
// Praefix, aber kein assignedTo — der Work-Loop haette sie sofort geclaimt.
|
|
||||||
const task = createTask(cwd, { title: 'codex: Blocker-Redesign', role: 'implementer' });
|
|
||||||
|
|
||||||
const pending = resolvePending(cwd, 'codex');
|
|
||||||
|
|
||||||
expect(pending.waitingTasks).toContain(task.id);
|
|
||||||
expect(hasPending(pending)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('beansprucht keine fremd adressierte Task', () => {
|
|
||||||
createTask(cwd, { title: 'kimi: nicht meins', role: 'implementer' });
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } 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 { resetPresence } from '../src/core/services/presenceService.js';
|
|
||||||
import { VERSION } from '../src/version.js';
|
|
||||||
|
|
||||||
interface HealthBody {
|
|
||||||
status: string;
|
|
||||||
version: string;
|
|
||||||
startedAt: string;
|
|
||||||
uptimeSec: number;
|
|
||||||
counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number };
|
|
||||||
agents: Array<{
|
|
||||||
name: string; role: string; state: string; taskId?: string; lastSeen?: string; lastSeenAgoSec?: number;
|
|
||||||
inLoop: boolean; loopSince?: string; loopExitReason?: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GET /health + lastSeen stamping (TSK-0226)', () => {
|
|
||||||
let cwd: string;
|
|
||||||
let app: ReturnType<typeof buildApp>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-health-'));
|
|
||||||
init(cwd, { projectName: 'health-test', yes: true });
|
|
||||||
resetPresence();
|
|
||||||
app = buildApp(cwd);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const getHealth = async (): Promise<HealthBody> => {
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/health' });
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
return JSON.parse(res.payload) as HealthBody;
|
|
||||||
};
|
|
||||||
|
|
||||||
it('returns the compact hub health shape', async () => {
|
|
||||||
const h = await getHealth();
|
|
||||||
expect(h.status).toBe('ok');
|
|
||||||
expect(h.version).toBe(VERSION);
|
|
||||||
expect(typeof h.uptimeSec).toBe('number');
|
|
||||||
expect(h.uptimeSec).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(typeof h.startedAt).toBe('string');
|
|
||||||
expect(h.counts).toMatchObject({ tasks: 0, open: 0, inProgress: 0, review: 0, unreadMessages: 0 });
|
|
||||||
expect(Array.isArray(h.agents)).toBe(true);
|
|
||||||
// The init roster seeds the default preferred agents.
|
|
||||||
expect(h.agents.map((a) => a.name)).toContain('claude');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stamps lastSeen on announce → agent shows active', async () => {
|
|
||||||
await app.inject({ method: 'POST', url: '/announce', payload: { agent: 'kimi', role: 'implementer' } });
|
|
||||||
const h = await getHealth();
|
|
||||||
const kimi = h.agents.find((a) => a.name === 'kimi');
|
|
||||||
expect(kimi).toBeDefined();
|
|
||||||
expect(kimi!.state).toBe('active');
|
|
||||||
expect(typeof kimi!.lastSeen).toBe('string');
|
|
||||||
expect(kimi!.lastSeenAgoSec).toBeLessThan(120);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows work-loop entry and the latest exit reason', async () => {
|
|
||||||
await app.inject({ method: 'POST', url: '/agents/codex/loop', payload: { active: true } });
|
|
||||||
let codex = (await getHealth()).agents.find((a) => a.name === 'codex')!;
|
|
||||||
expect(codex.inLoop).toBe(true);
|
|
||||||
expect(codex.loopSince).toBeDefined();
|
|
||||||
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/agents/codex/loop',
|
|
||||||
payload: { active: false, reason: 'client timeout' },
|
|
||||||
});
|
|
||||||
codex = (await getHealth()).agents.find((a) => a.name === 'codex')!;
|
|
||||||
expect(codex.inLoop).toBe(false);
|
|
||||||
expect(codex.loopExitReason).toBe('client timeout');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stamps lastSeen on message send and counts the unread message', async () => {
|
|
||||||
await app.inject({ method: 'POST', url: '/messages', payload: { from: 'codex', to: 'claude', text: 'ping' } });
|
|
||||||
const h = await getHealth();
|
|
||||||
const codex = h.agents.find((a) => a.name === 'codex');
|
|
||||||
expect(codex!.state).toBe('active');
|
|
||||||
expect(h.counts.unreadMessages).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stamps lastSeen on claim → agent shows busy-on-TSK-X', async () => {
|
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer', assignedTo: 'codex' } });
|
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
|
|
||||||
const h = await getHealth();
|
|
||||||
const codex = h.agents.find((a) => a.name === 'codex');
|
|
||||||
expect(codex!.state).toBe('busy');
|
|
||||||
expect(codex!.taskId).toBe('TSK-0001');
|
|
||||||
expect(h.counts.inProgress).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stamps lastSeen on task-log lines and review submissions', async () => {
|
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer', assignedTo: 'kimi' } });
|
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'kimi' } });
|
|
||||||
await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'working', agent: 'kimi' } });
|
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
|
||||||
const h = await getHealth();
|
|
||||||
const kimi = h.agents.find((a) => a.name === 'kimi');
|
|
||||||
expect(kimi!.state).toBe('active');
|
|
||||||
expect(kimi!.lastSeen).toBeDefined();
|
|
||||||
expect(h.counts.review).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { init } from '../src/cli/commands/init.js';
|
|
||||||
import { loadConfig, saveConfig } from '../src/core/config.js';
|
|
||||||
import { agentIdentity, resolveAgentName } from '../src/core/services/identityService.js';
|
|
||||||
|
|
||||||
describe('agent identity resolution', () => {
|
|
||||||
let cwd: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-identity-'));
|
|
||||||
init(cwd, { projectName: 'identity-test', yes: true });
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
config.agents = {
|
|
||||||
claude: { role: 'architect', aliases: ['windows-claude'] },
|
|
||||||
kimi: { role: 'implementer', aliases: ['kimi-ah'] },
|
|
||||||
};
|
|
||||||
config.roles.architect.preferredAgent = 'claude';
|
|
||||||
config.roles.implementer.preferredAgent = 'kimi';
|
|
||||||
saveConfig(cwd, config);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => rmSync(cwd, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
it('maps aliases and roles to the canonical roster agent', () => {
|
|
||||||
expect(resolveAgentName(cwd, 'kimi-ah')).toBe('kimi');
|
|
||||||
expect(resolveAgentName(cwd, 'implementer')).toBe('kimi');
|
|
||||||
expect(resolveAgentName(cwd, 'architect')).toBe('claude');
|
|
||||||
expect(agentIdentity(cwd, 'kimi').names).toEqual(new Set(['kimi', 'kimi-ah', 'implementer']));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an unknown recipient when a roster is configured', () => {
|
|
||||||
expect(() => resolveAgentName(cwd, 'ghost-agent')).toThrow(/unknown agent, alias, or role/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { init } from '../src/cli/commands/init.js';
|
|
||||||
import { loadConfig, saveConfig } from '../src/core/config.js';
|
|
||||||
import {
|
|
||||||
DEFAULT_TASK_LIST_LIMIT,
|
|
||||||
DEFAULT_WORK_TIMEOUT_SEC,
|
|
||||||
boundedTaskList,
|
|
||||||
resolveMcpContext,
|
|
||||||
} from '../src/mcp/server.js';
|
|
||||||
import { installMcp } from '../src/mcp/install.js';
|
|
||||||
|
|
||||||
describe('MCP context resolution', () => {
|
|
||||||
let cwd: string;
|
|
||||||
const originalEnv = process.env.AGENTHUB_SERVER;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-mcp-'));
|
|
||||||
init(cwd, { projectName: 'mcp-test', yes: true });
|
|
||||||
delete process.env.AGENTHUB_SERVER;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (originalEnv === undefined) delete process.env.AGENTHUB_SERVER;
|
|
||||||
else process.env.AGENTHUB_SERVER = originalEnv;
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('self-heals a stale configured server URL before MCP tools call remote APIs', async () => {
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
config.serverUrl = 'http://agenthub.local:3377';
|
|
||||||
saveConfig(cwd, config);
|
|
||||||
|
|
||||||
const context = await resolveMcpContext(cwd, {
|
|
||||||
probe: async () => false,
|
|
||||||
discover: async () => 'http://127.0.0.1:3377',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(context).toEqual({ root: cwd, serverUrl: 'http://127.0.0.1:3377' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('MCP bounded defaults', () => {
|
|
||||||
it('uses a client-safe work timeout', () => {
|
|
||||||
expect(DEFAULT_WORK_TIMEOUT_SEC).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns task lists newest-first with explicit truncation metadata', () => {
|
|
||||||
const tasks = Array.from({ length: 55 }, (_, i) => ({
|
|
||||||
id: `TSK-${String(i).padStart(4, '0')}`,
|
|
||||||
updatedAt: new Date(Date.UTC(2026, 0, i + 1)).toISOString(),
|
|
||||||
}));
|
|
||||||
const result = boundedTaskList(tasks);
|
|
||||||
expect(result).toMatchObject({ total: 55, returned: DEFAULT_TASK_LIST_LIMIT, omitted: 5 });
|
|
||||||
expect(result.tasks[0].id).toBe('TSK-0054');
|
|
||||||
expect(result.tasks.at(-1)?.id).toBe('TSK-0005');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('MCP installation', () => {
|
|
||||||
let cwd: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-mcp-install-'));
|
|
||||||
init(cwd, { projectName: 'mcp-install-test', yes: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('binds the push bridge at process startup when --agent is provided', () => {
|
|
||||||
installMcp(cwd, { agent: 'codex' });
|
|
||||||
|
|
||||||
const json = JSON.parse(readFileSync(join(cwd, '.mcp.json'), 'utf-8')) as {
|
|
||||||
mcpServers: { agenthub: { args: string[] } };
|
|
||||||
};
|
|
||||||
expect(json.mcpServers.agenthub.args).toEqual(['mcp', '--agent', 'codex']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the shared project config agent-neutral by default', () => {
|
|
||||||
installMcp(cwd);
|
|
||||||
|
|
||||||
const json = JSON.parse(readFileSync(join(cwd, '.mcp.json'), 'utf-8')) as {
|
|
||||||
mcpServers: { agenthub: { args: string[] } };
|
|
||||||
};
|
|
||||||
expect(json.mcpServers.agenthub.args).toEqual(['mcp']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
||||||
import { bindPushChannel } from '../src/mcp/pushChannel.js';
|
|
||||||
|
|
||||||
function sseFrame(seq: number): Uint8Array {
|
|
||||||
return new TextEncoder().encode(
|
|
||||||
`id: ${seq}\ndata: ${JSON.stringify({
|
|
||||||
type: 'task',
|
|
||||||
action: 'updated',
|
|
||||||
id: 'TSK-0042',
|
|
||||||
status: 'open',
|
|
||||||
assignedTo: 'codex',
|
|
||||||
})}\n\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('MCP push channel replay cursor', () => {
|
|
||||||
const realFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = realFetch;
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reconnects with Last-Event-ID after receiving a durable event', async () => {
|
|
||||||
const eventHeaders: Headers[] = [];
|
|
||||||
let eventRequests = 0;
|
|
||||||
let resolveSecondRequest!: () => void;
|
|
||||||
const secondRequest = new Promise<void>((resolve) => {
|
|
||||||
resolveSecondRequest = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const url = String(input);
|
|
||||||
if (url.endsWith('/agents/codex/identity')) {
|
|
||||||
return new Response(JSON.stringify({ canonical: 'codex', names: ['codex'] }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
eventHeaders.push(new Headers(init?.headers));
|
|
||||||
eventRequests += 1;
|
|
||||||
if (eventRequests === 1) {
|
|
||||||
return new Response(new ReadableStream<Uint8Array>({
|
|
||||||
start(controller) {
|
|
||||||
controller.enqueue(sseFrame(42));
|
|
||||||
controller.close();
|
|
||||||
},
|
|
||||||
}), { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveSecondRequest();
|
|
||||||
return new Response(new ReadableStream<Uint8Array>({
|
|
||||||
start() {
|
|
||||||
// Keep the retry connected; the test only needs its request headers.
|
|
||||||
},
|
|
||||||
}), { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
const sendLoggingMessage = vi.fn(async () => undefined);
|
|
||||||
const fakeServer = {
|
|
||||||
server: {
|
|
||||||
getClientCapabilities: () => null,
|
|
||||||
sendLoggingMessage,
|
|
||||||
},
|
|
||||||
} as unknown as McpServer;
|
|
||||||
|
|
||||||
bindPushChannel(fakeServer, 'http://agenthub.test', 'codex');
|
|
||||||
await secondRequest;
|
|
||||||
|
|
||||||
expect(sendLoggingMessage).toHaveBeenCalledTimes(1);
|
|
||||||
expect(eventHeaders).toHaveLength(2);
|
|
||||||
expect(eventHeaders[0].get('Last-Event-ID')).toBeNull();
|
|
||||||
expect(eventHeaders[1].get('Last-Event-ID')).toBe('42');
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
@ -1,210 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression tests for the realtime reliability patch (TSK-0224):
|
|
||||||
* (a) polling fallback claims a task even when its SSE event is lost;
|
|
||||||
* (b) parseSSEBuffer accepts CRLF frames and joins multiple data: lines;
|
|
||||||
* (c) delivered-but-unacked messages stay visible in the inbox;
|
|
||||||
* (d) discovery self-heal falls back when the configured URL is dead.
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { mkdtempSync, rmSync } from 'fs';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { init } from '../src/cli/commands/init.js';
|
|
||||||
import { workAgent } from '../src/cli/commands/work.js';
|
|
||||||
import { parseSSEBuffer } from '../src/cli/commands/watch.js';
|
|
||||||
import { startServer } from '../src/server/index.js';
|
|
||||||
import { createMessage, listInbox, getMessage } from '../src/core/services/messageService.js';
|
|
||||||
import { probeServer, resolveReachableServerUrl } from '../src/discovery.js';
|
|
||||||
import type { Task } from '../src/core/schema.js';
|
|
||||||
|
|
||||||
// ─── (a) polling fallback: claim despite a lost SSE event ────────────────────
|
|
||||||
|
|
||||||
describe('work — polling fallback (lost SSE event)', () => {
|
|
||||||
let cwd: string;
|
|
||||||
let server: Awaited<ReturnType<typeof startServer>>;
|
|
||||||
const realFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-poll-'));
|
|
||||||
init(cwd, { projectName: 'poll-test', yes: true });
|
|
||||||
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
|
||||||
|
|
||||||
// Suppress the SSE channel: /events returns an open stream that never
|
|
||||||
// delivers a frame. Every other request passes through to the real server.
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
||||||
if (url.endsWith('/events')) {
|
|
||||||
const stream = new ReadableStream<Uint8Array>({
|
|
||||||
start(controller) {
|
|
||||||
init?.signal?.addEventListener('abort', () => {
|
|
||||||
try {
|
|
||||||
controller.close();
|
|
||||||
} catch {
|
|
||||||
/* already closed */
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Never enqueue: the SSE frame is "lost".
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
|
|
||||||
}
|
|
||||||
return realFetch(input, init);
|
|
||||||
}) as typeof fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
globalThis.fetch = realFetch;
|
|
||||||
await server.app.close().catch(() => undefined);
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('claims a task via the polling fallback when its SSE event never arrives', async () => {
|
|
||||||
const workDone = workAgent({
|
|
||||||
serverUrl: server.url,
|
|
||||||
projectCwd: cwd,
|
|
||||||
agent: 'kimi',
|
|
||||||
role: 'implementer',
|
|
||||||
timeoutSec: 8,
|
|
||||||
pollIntervalMs: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delegate a task after the wait started. The SSE event is swallowed by the
|
|
||||||
// stub above, so only the polling fallback can notice it.
|
|
||||||
await new Promise((r) => setTimeout(r, 300));
|
|
||||||
await realFetch(`${server.url}/tasks`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ title: 'kimi: claimed via polling', role: 'implementer' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
await workDone; // must resolve via the poll, long before the 8s timeout
|
|
||||||
|
|
||||||
const tasks = (await realFetch(`${server.url}/tasks`).then((r) => r.json())) as Task[];
|
|
||||||
const mine = tasks.find((t) => (t.title ?? '').startsWith('kimi:'));
|
|
||||||
expect(mine).toBeDefined();
|
|
||||||
expect(mine?.status).toBe('in_progress');
|
|
||||||
expect(mine?.assignedTo).toBe('kimi');
|
|
||||||
}, 10000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── (b) CRLF-tolerant SSE parser ────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('parseSSEBuffer — CRLF + multi-line data', () => {
|
|
||||||
it('parses CRLF-framed events', () => {
|
|
||||||
const buf = 'data: {"type":"task","action":"created","id":"TSK-0001"}\r\n\r\n';
|
|
||||||
const { events, remaining } = parseSSEBuffer(buf);
|
|
||||||
expect(events).toHaveLength(1);
|
|
||||||
expect(events[0].id).toBe('TSK-0001');
|
|
||||||
expect(remaining).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses mixed LF/CRLF frames and CRLF keepalives', () => {
|
|
||||||
const buf =
|
|
||||||
':\r\n\r\n' +
|
|
||||||
'data: {"type":"memory","action":"created","id":"MEM-001"}\n\n' +
|
|
||||||
'data: {"type":"decision","action":"created","id":"DEC-001"}\r\n\r\n';
|
|
||||||
const { events, remaining } = parseSSEBuffer(buf);
|
|
||||||
expect(events).toHaveLength(2);
|
|
||||||
expect(events[0].id).toBe('MEM-001');
|
|
||||||
expect(events[1].id).toBe('DEC-001');
|
|
||||||
expect(remaining).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('joins multiple data: lines of one event with a newline', () => {
|
|
||||||
const buf = 'data: {"type":"task",\r\ndata: "id":"TSK-0002"}\r\n\r\n';
|
|
||||||
const { events } = parseSSEBuffer(buf);
|
|
||||||
expect(events).toHaveLength(1);
|
|
||||||
expect(events[0].type).toBe('task');
|
|
||||||
expect(events[0].id).toBe('TSK-0002');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps an incomplete CRLF tail in `remaining`', () => {
|
|
||||||
const buf = 'data: {"type":"task","id":"TSK-0001"}\r\n\r\ndata: {"type":"deci';
|
|
||||||
const { events, remaining } = parseSSEBuffer(buf);
|
|
||||||
expect(events).toHaveLength(1);
|
|
||||||
expect(remaining).toBe('data: {"type":"deci');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── (c) delivered-unacked messages stay visible ─────────────────────────────
|
|
||||||
|
|
||||||
describe('message semantics — delivered stays visible until explicit ack/read', () => {
|
|
||||||
let cwd: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-msg-vis-'));
|
|
||||||
init(cwd, { projectName: 'msg-vis', yes: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a surfaced (delivered) message stays in the default inbox and never re-wakes', () => {
|
|
||||||
const msg = createMessage(cwd, { from: 'claude', to: 'kimi', text: 'look at this' });
|
|
||||||
|
|
||||||
// The work-loop drain path: fetch unread. First call surfaces it and flips
|
|
||||||
// it to delivered; nothing marks it read.
|
|
||||||
const first = listInbox(cwd, 'kimi', { unreadOnly: true });
|
|
||||||
expect(first).toHaveLength(1);
|
|
||||||
expect(first[0].status).toBe('delivered');
|
|
||||||
expect(getMessage(cwd, msg.id).message.status).toBe('delivered');
|
|
||||||
|
|
||||||
// No spin: the unread-filtered wake check is empty on the next pass…
|
|
||||||
expect(listInbox(cwd, 'kimi', { unreadOnly: true })).toHaveLength(0);
|
|
||||||
|
|
||||||
// …but the info stays visible in the default inbox until explicit ack/read.
|
|
||||||
const inbox = listInbox(cwd, 'kimi');
|
|
||||||
expect(inbox.map((m) => m.id)).toContain(msg.id);
|
|
||||||
expect(inbox.find((m) => m.id === msg.id)?.status).toBe('delivered');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── (d) discovery self-heal ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('discovery self-heal — stale configured URL', () => {
|
|
||||||
let cwd: string;
|
|
||||||
let server: Awaited<ReturnType<typeof startServer>>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-heal-'));
|
|
||||||
init(cwd, { projectName: 'heal-test', yes: true });
|
|
||||||
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await server.app.close().catch(() => undefined);
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the discovered URL when the configured one is dead', async () => {
|
|
||||||
const discovered = await resolveReachableServerUrl('http://127.0.0.1:1', {
|
|
||||||
discover: async () => server.url,
|
|
||||||
});
|
|
||||||
expect(discovered).toBe(server.url);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the configured URL when it answers (discovery not consulted)', async () => {
|
|
||||||
let discoverCalled = false;
|
|
||||||
const resolved = await resolveReachableServerUrl(server.url, {
|
|
||||||
discover: async () => {
|
|
||||||
discoverCalled = true;
|
|
||||||
return 'http://example.invalid:9';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(resolved).toBe(server.url);
|
|
||||||
expect(discoverCalled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined when the configured URL is dead and nothing is discovered', async () => {
|
|
||||||
const resolved = await resolveReachableServerUrl('http://127.0.0.1:1', {
|
|
||||||
discover: async () => undefined,
|
|
||||||
});
|
|
||||||
expect(resolved).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('probeServer distinguishes a live server from a dead one', async () => {
|
|
||||||
expect(await probeServer(server.url)).toBe(true);
|
|
||||||
expect(await probeServer('http://127.0.0.1:1', 300)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { init } from '../src/cli/commands/init.js';
|
|
||||||
import { loadConfig, saveConfig } from '../src/core/config.js';
|
|
||||||
import { setPreferredAgent } from '../src/core/services/roleService.js';
|
|
||||||
import { getRoster } from '../src/core/services/rosterService.js';
|
|
||||||
|
|
||||||
describe('preferred role switching', () => {
|
|
||||||
let cwd: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-role-switch-'));
|
|
||||||
init(cwd, { projectName: 'role-switch', yes: true });
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
config.agents = {
|
|
||||||
claude: { role: 'architect', dispatch: 'loop', aliases: [] },
|
|
||||||
codex: { role: 'implementer', dispatch: 'loop', aliases: [] },
|
|
||||||
kimi: { role: 'implementer', dispatch: 'loop', aliases: [] },
|
|
||||||
};
|
|
||||||
saveConfig(cwd, config);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('promotes codex and moves claude to its remaining reviewer role', () => {
|
|
||||||
expect(setPreferredAgent(cwd, 'architect', 'codex')).toEqual({
|
|
||||||
role: 'architect',
|
|
||||||
agent: 'codex',
|
|
||||||
previousAgent: 'claude',
|
|
||||||
});
|
|
||||||
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
expect(config.roles.architect.preferredAgent).toBe('codex');
|
|
||||||
expect(config.agents?.codex.role).toBe('architect');
|
|
||||||
expect(config.agents?.claude.role).toBe('reviewer');
|
|
||||||
expect(getRoster(cwd).find((a) => a.name === 'codex')?.role).toBe('architect');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('restores claude and returns codex to implementer', () => {
|
|
||||||
setPreferredAgent(cwd, 'architect', 'codex');
|
|
||||||
setPreferredAgent(cwd, 'architect', 'claude');
|
|
||||||
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
expect(config.agents?.claude.role).toBe('architect');
|
|
||||||
expect(config.agents?.codex.role).toBe('implementer');
|
|
||||||
expect(getRoster(cwd).find((a) => a.name === 'claude')?.role).toBe('architect');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unknown agents without changing the config', () => {
|
|
||||||
expect(() => setPreferredAgent(cwd, 'architect', 'nobody')).toThrow('Unknown agent');
|
|
||||||
expect(loadConfig(cwd).roles.architect.preferredAgent).toBe('claude');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -4,7 +4,6 @@ import { tmpdir } from 'os';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { buildApp } from '../src/server/index.js';
|
import { buildApp } from '../src/server/index.js';
|
||||||
import { init } from '../src/cli/commands/init.js';
|
import { init } from '../src/cli/commands/init.js';
|
||||||
import { loadConfig, saveConfig } from '../src/core/config.js';
|
|
||||||
|
|
||||||
describe('server routes', () => {
|
describe('server routes', () => {
|
||||||
let cwd: string;
|
let cwd: string;
|
||||||
@ -38,57 +37,6 @@ describe('server routes', () => {
|
|||||||
expect(JSON.parse(res.payload)).toHaveLength(1);
|
expect(JSON.parse(res.payload)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a compact architect pulse with reviews, dormant agents, messages, and event delta', async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST', url: '/tasks',
|
|
||||||
payload: { title: 'Pulse review', 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: 'POST', url: '/agents/codex/loop', payload: { active: true } });
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST', url: '/agents/codex/loop',
|
|
||||||
payload: { active: false, reason: 'turn ended' },
|
|
||||||
});
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST', url: '/messages',
|
|
||||||
payload: { from: 'codex', to: 'architect', text: 'review ready' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/architect/pulse?sinceSeq=0' });
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const pulse = JSON.parse(res.payload);
|
|
||||||
expect(pulse.reviews).toEqual([
|
|
||||||
expect.objectContaining({ id: 'TSK-0001', assignedTo: 'codex' }),
|
|
||||||
]);
|
|
||||||
expect(pulse.dormantAgents).toEqual([
|
|
||||||
expect.objectContaining({ name: 'codex', loopExitReason: 'turn ended' }),
|
|
||||||
]);
|
|
||||||
expect(pulse.messages).toEqual([
|
|
||||||
expect.objectContaining({ from: 'codex' }),
|
|
||||||
]);
|
|
||||||
expect(pulse.events.length).toBeGreaterThan(0);
|
|
||||||
expect(pulse.nextSeq).toBeGreaterThan(0);
|
|
||||||
expect(pulse.omitted).toEqual(expect.objectContaining({ messages: 0, events: expect.any(Number) }));
|
|
||||||
expect(pulse.reviews[0].title).toBeUndefined();
|
|
||||||
|
|
||||||
const delta = await app.inject({ method: 'GET', url: `/architect/pulse?since=${pulse.nextSeq}` });
|
|
||||||
const deltaPulse = JSON.parse(delta.payload);
|
|
||||||
expect(deltaPulse.events).toEqual([]);
|
|
||||||
expect(deltaPulse.nextSeq).toBe(pulse.nextSeq);
|
|
||||||
expect(delta.payload.length).toBeLessThan(1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when assigning to an unknown agent', async () => {
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
config.agents = { codex: { role: 'implementer', dispatch: 'loop' } };
|
|
||||||
saveConfig(cwd, config);
|
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
|
||||||
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { assignedTo: 'unknown-agent' } });
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(res.payload).toContain('Unknown agent');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 for unknown task', async () => {
|
it('returns 404 for unknown task', async () => {
|
||||||
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-9999' });
|
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-9999' });
|
||||||
expect(res.statusCode).toBe(404);
|
expect(res.statusCode).toBe(404);
|
||||||
@ -116,33 +64,11 @@ describe('server routes', () => {
|
|||||||
|
|
||||||
it('PATCH /tasks/:id → review', async () => {
|
it('PATCH /tasks/:id → review', async () => {
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
|
|
||||||
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(JSON.parse(res.payload).status).toBe('review');
|
expect(JSON.parse(res.payload).status).toBe('review');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects open → review and records the failed transition in the task log', async () => {
|
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
|
||||||
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(JSON.parse(res.payload).error).toMatch(/open.*review/i);
|
|
||||||
const log = JSON.parse((await app.inject({ method: 'GET', url: '/tasks/TSK-0001/log' })).payload).log;
|
|
||||||
expect(log.some((entry: { text: string }) => entry.text.includes('Rejected transition'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves claimedBy through review and done', async () => {
|
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
|
||||||
await app.inject({
|
|
||||||
method: 'PATCH', url: '/tasks/TSK-0001',
|
|
||||||
payload: { status: 'in_progress', assignedTo: 'codex' },
|
|
||||||
});
|
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
|
||||||
const done = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
|
|
||||||
expect(done.statusCode).toBe(200);
|
|
||||||
expect(JSON.parse(done.payload)).toMatchObject({ status: 'done', claimedBy: 'codex', doneBy: 'codex' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a second claim of the same task returns a clean 400 (race guard, board does not 500) — TSK-0007', async () => {
|
it('a second claim of the same task returns a clean 400 (race guard, board does not 500) — TSK-0007', async () => {
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
||||||
const first = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'kimi' } });
|
const first = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'kimi' } });
|
||||||
@ -157,7 +83,6 @@ describe('server routes', () => {
|
|||||||
|
|
||||||
it('PATCH /tasks/:id → review accepts reviewer separately from assignedTo', async () => {
|
it('PATCH /tasks/:id → review accepts reviewer separately from assignedTo', async () => {
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
|
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' } });
|
|
||||||
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review', reviewer: 'claude' } });
|
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review', reviewer: 'claude' } });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
const task = JSON.parse(res.payload);
|
const task = JSON.parse(res.payload);
|
||||||
@ -178,8 +103,8 @@ describe('server routes', () => {
|
|||||||
|
|
||||||
it('PATCH /tasks/:id → open (reopen)', async () => {
|
it('PATCH /tasks/:id → open (reopen)', async () => {
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
|
// First cancel it, then reopen
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'cancelled' } });
|
||||||
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } });
|
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(JSON.parse(res.payload).status).toBe('open');
|
expect(JSON.parse(res.payload).status).toBe('open');
|
||||||
@ -188,7 +113,6 @@ describe('server routes', () => {
|
|||||||
it('review and cancelled tasks appear in GET /tasks list', async () => {
|
it('review and cancelled tasks appear in GET /tasks list', async () => {
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'B', role: 'implementer' } });
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'B', role: 'implementer' } });
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
|
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0002', payload: { status: 'cancelled' } });
|
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0002', payload: { status: 'cancelled' } });
|
||||||
|
|
||||||
@ -279,8 +203,6 @@ describe('server routes', () => {
|
|||||||
|
|
||||||
it('GET /tasks/:id/activity includes tokens/duration from done metadata', async () => {
|
it('GET /tasks/:id/activity includes tokens/duration from done metadata', async () => {
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer' } });
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer' } });
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
|
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
|
||||||
await app.inject({
|
await app.inject({
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
url: '/tasks/TSK-0001',
|
url: '/tasks/TSK-0001',
|
||||||
@ -329,16 +251,6 @@ describe('server routes', () => {
|
|||||||
decisions: ['DEC-0001'],
|
decisions: ['DEC-0001'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/tasks/TSK-0001',
|
|
||||||
payload: { status: 'in_progress', assignedTo: 'codex' },
|
|
||||||
});
|
|
||||||
await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/tasks/TSK-0001',
|
|
||||||
payload: { status: 'review' },
|
|
||||||
});
|
|
||||||
await app.inject({
|
await app.inject({
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
url: '/tasks/TSK-0001',
|
url: '/tasks/TSK-0001',
|
||||||
@ -357,8 +269,6 @@ describe('server routes', () => {
|
|||||||
|
|
||||||
it('serves the activity page with tasks only — messaging split out to /messages', async () => {
|
it('serves the activity page with tasks only — messaging split out to /messages', async () => {
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Done item', role: 'implementer' } });
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Done item', role: 'implementer' } });
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
|
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
|
||||||
await app.inject({
|
await app.inject({
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
url: '/tasks/TSK-0001',
|
url: '/tasks/TSK-0001',
|
||||||
|
|||||||
@ -1,203 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression tests for TSK-0237: single-claim semantics in the work loop.
|
|
||||||
*
|
|
||||||
* Bug (24.07. ~01:54): after the TSK-0230 auto-wake, an agent's waiting work
|
|
||||||
* loop claimed EVERY task assigned to it in parallel (codex held TSK-0222 +
|
|
||||||
* TSK-0235 at once) — the board lied about who works on what.
|
|
||||||
*
|
|
||||||
* Soll:
|
|
||||||
* (a) several assigned tasks ⇒ the loop claims exactly ONE (highest
|
|
||||||
* priority, oldest createdAt breaks ties); the rest stays open.
|
|
||||||
* (b) the next auto-claim happens only after the active task reaches
|
|
||||||
* review/done (or is reopened back).
|
|
||||||
* (c) server-side guard: a claim while holding an in_progress task → error
|
|
||||||
* (architect exempt, no --force for agents).
|
|
||||||
* (d) the TSK-0230 auto-wake suite keeps passing.
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { mkdtempSync, rmSync } from 'fs';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { init } from '../src/cli/commands/init.js';
|
|
||||||
import { workAgent } from '../src/cli/commands/work.js';
|
|
||||||
import { startServer } from '../src/server/index.js';
|
|
||||||
import { createTask, claimTask, getTask } from '../src/core/services/taskService.js';
|
|
||||||
import type { Task } from '../src/core/schema.js';
|
|
||||||
|
|
||||||
const AGENT = 'kimi';
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((r) => setTimeout(r, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createAndAssign(serverUrl: string, title: string, priority: string): Promise<string> {
|
|
||||||
const res = await fetch(`${serverUrl}/tasks`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ title, role: 'implementer', priority }),
|
|
||||||
});
|
|
||||||
const task = (await res.json()) as Task;
|
|
||||||
await fetch(`${serverUrl}/tasks/${task.id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ assignedTo: AGENT }),
|
|
||||||
});
|
|
||||||
return task.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function taskViaApi(serverUrl: string, id: string): Promise<Task> {
|
|
||||||
const res = (await fetch(`${serverUrl}/tasks/${id}`).then((r) => r.json())) as { task: Task };
|
|
||||||
return res.task;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function claimViaApi(serverUrl: string, id: string, agent: string): Promise<Response> {
|
|
||||||
return fetch(`${serverUrl}/tasks/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ status: 'in_progress', assignedTo: agent }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('single-claim semantics (TSK-0237)', () => {
|
|
||||||
let cwd: string;
|
|
||||||
let server: Awaited<ReturnType<typeof startServer>>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-single-claim-'));
|
|
||||||
init(cwd, { projectName: 'single-claim-test', yes: true });
|
|
||||||
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await server.app.close().catch(() => undefined);
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('(a) claims exactly ONE of several assigned tasks — highest priority first', async () => {
|
|
||||||
const low = await createAndAssign(server.url, 'kimi: low prio', 'low');
|
|
||||||
await sleep(10);
|
|
||||||
const high = await createAndAssign(server.url, 'kimi: high prio', 'high');
|
|
||||||
|
|
||||||
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
|
|
||||||
|
|
||||||
const highTask = await taskViaApi(server.url, high);
|
|
||||||
expect(highTask.status).toBe('in_progress');
|
|
||||||
expect(highTask.assignedTo).toBe(AGENT);
|
|
||||||
|
|
||||||
// The other task must stay open — no parallel claim.
|
|
||||||
const lowTask = await taskViaApi(server.url, low);
|
|
||||||
expect(lowTask.status).toBe('open');
|
|
||||||
expect(lowTask.assignedTo).toBe(AGENT);
|
|
||||||
|
|
||||||
// A second work run while busy claims nothing (single-claim), it just waits.
|
|
||||||
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 1 });
|
|
||||||
expect((await taskViaApi(server.url, low)).status).toBe('open');
|
|
||||||
}, 10_000);
|
|
||||||
|
|
||||||
it('(a2) breaks priority ties by oldest createdAt', async () => {
|
|
||||||
const older = await createAndAssign(server.url, 'kimi: older medium', 'medium');
|
|
||||||
await sleep(10);
|
|
||||||
const newer = await createAndAssign(server.url, 'kimi: newer medium', 'medium');
|
|
||||||
|
|
||||||
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
|
|
||||||
|
|
||||||
expect((await taskViaApi(server.url, older)).status).toBe('in_progress');
|
|
||||||
expect((await taskViaApi(server.url, newer)).status).toBe('open');
|
|
||||||
}, 10_000);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DEC-0035 (löst das frühere TSK-0237-Verhalten ab): `review` bindet den
|
|
||||||
* Agenten genauso wie `in_progress`. Früher galt er in der Sekunde des
|
|
||||||
* Einreichens als frei und griff sich die nächste Task — wies der Architekt
|
|
||||||
* die Review danach zurück, blockierte der Ein-Task-Guard den Reopen und die
|
|
||||||
* zurückgewiesene Arbeit blieb unbemerkt liegen (so ging der Reopen von
|
|
||||||
* TSK-0218 verloren). Erst das Approve gibt den Agenten frei.
|
|
||||||
*/
|
|
||||||
it('(b) claimt die nächste Task NICHT bei review — erst nach dem Architekten-Approve', async () => {
|
|
||||||
const first = await createAndAssign(server.url, 'kimi: first', 'high');
|
|
||||||
await sleep(10);
|
|
||||||
const second = await createAndAssign(server.url, 'kimi: second', 'medium');
|
|
||||||
|
|
||||||
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
|
|
||||||
expect((await taskViaApi(server.url, first)).status).toBe('in_progress');
|
|
||||||
expect((await taskViaApi(server.url, second)).status).toBe('open');
|
|
||||||
|
|
||||||
const patch = (id: string, body: Record<string, unknown>) =>
|
|
||||||
fetch(`${server.url}/tasks/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Eingereicht — der Agent bleibt gebunden, solange das Review offen ist.
|
|
||||||
await patch(first, { status: 'review' });
|
|
||||||
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
|
|
||||||
expect((await taskViaApi(server.url, second)).status).toBe('open');
|
|
||||||
|
|
||||||
// Approve → jetzt erst ist er frei für die nächste Task.
|
|
||||||
await patch(first, { status: 'done' });
|
|
||||||
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
|
|
||||||
expect((await taskViaApi(server.url, second)).status).toBe('in_progress');
|
|
||||||
}, 14_000);
|
|
||||||
|
|
||||||
it('(b2) ein Reopen gibt dem Agenten die ALTE Task zurück, nicht die nächste', async () => {
|
|
||||||
const first = await createAndAssign(server.url, 'kimi: first', 'high');
|
|
||||||
await sleep(10);
|
|
||||||
const second = await createAndAssign(server.url, 'kimi: second', 'medium');
|
|
||||||
|
|
||||||
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
|
|
||||||
const patch = (id: string, body: Record<string, unknown>) =>
|
|
||||||
fetch(`${server.url}/tasks/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
await patch(first, { status: 'review' });
|
|
||||||
await patch(first, { status: 'open' }); // Architekt weist zurück
|
|
||||||
|
|
||||||
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
|
|
||||||
// Die zurückgewiesene Arbeit muss gewinnen — genau hier ging früher ein
|
|
||||||
// Reopen verloren, weil der Agent schon an der nächsten Task hing.
|
|
||||||
expect((await taskViaApi(server.url, first)).status).toBe('in_progress');
|
|
||||||
expect((await taskViaApi(server.url, second)).status).toBe('open');
|
|
||||||
}, 14_000);
|
|
||||||
|
|
||||||
it('(c) server guard: claim while holding an in_progress task → 400, architect exempt', async () => {
|
|
||||||
const t1 = await createAndAssign(server.url, 'kimi: held', 'medium');
|
|
||||||
const t2 = await createAndAssign(server.url, 'kimi: blocked', 'medium');
|
|
||||||
|
|
||||||
const ok = await claimViaApi(server.url, t1, AGENT);
|
|
||||||
expect(ok.status).toBe(200);
|
|
||||||
|
|
||||||
const blocked = await claimViaApi(server.url, t2, AGENT);
|
|
||||||
expect(blocked.status).toBe(400);
|
|
||||||
const body = (await blocked.json()) as { error?: string; message?: string };
|
|
||||||
expect(JSON.stringify(body)).toContain(t1);
|
|
||||||
// The blocked task stays open.
|
|
||||||
expect((await taskViaApi(server.url, t2)).status).toBe('open');
|
|
||||||
|
|
||||||
// Architect (default config: claude) may hold several threads.
|
|
||||||
const t3 = await createAndAssign(server.url, 'claude: thread one', 'medium');
|
|
||||||
const t4 = await createAndAssign(server.url, 'claude: thread two', 'medium');
|
|
||||||
expect((await claimViaApi(server.url, t3, 'claude')).status).toBe(200);
|
|
||||||
expect((await claimViaApi(server.url, t4, 'claude')).status).toBe(200);
|
|
||||||
}, 10_000);
|
|
||||||
|
|
||||||
it('(c2) claimTask unit: guard throws, same-task re-claim stays idempotent', () => {
|
|
||||||
const a = createTask(cwd, { title: 'kimi: a', role: 'implementer' });
|
|
||||||
const b = createTask(cwd, { title: 'kimi: b', role: 'implementer' });
|
|
||||||
|
|
||||||
claimTask(cwd, a.id, AGENT);
|
|
||||||
expect(() => claimTask(cwd, b.id, AGENT)).toThrowError(new RegExp(a.id));
|
|
||||||
// Idempotent re-claim of the SAME task is still a no-op.
|
|
||||||
expect(() => claimTask(cwd, a.id, AGENT)).not.toThrow();
|
|
||||||
// Architect exempt.
|
|
||||||
const c = createTask(cwd, { title: 'claude: x', role: 'architect' });
|
|
||||||
const d = createTask(cwd, { title: 'claude: y', role: 'architect' });
|
|
||||||
claimTask(cwd, c.id, 'claude');
|
|
||||||
expect(() => claimTask(cwd, d.id, 'claude')).not.toThrow();
|
|
||||||
|
|
||||||
const { task } = getTask(cwd, b.id);
|
|
||||||
expect(task.status).toBe('open');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs';
|
import { mkdtempSync, rmSync, readFileSync } from 'fs';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { buildApp } from '../src/server/index.js';
|
import { buildApp } from '../src/server/index.js';
|
||||||
@ -17,9 +17,8 @@ import { eventBus } from '../src/server/events.js';
|
|||||||
import type { AgentHubEvent } from '../src/server/events.js';
|
import type { AgentHubEvent } from '../src/server/events.js';
|
||||||
import { parseSSEBuffer, formatEvent, watchEvents } from '../src/cli/commands/watch.js';
|
import { parseSSEBuffer, formatEvent, watchEvents } from '../src/cli/commands/watch.js';
|
||||||
import { init } from '../src/cli/commands/init.js';
|
import { init } from '../src/cli/commands/init.js';
|
||||||
import { getFsWatchErrors, resetFsWatchErrors, startEntityWatcher } from '../src/server/fsWatch.js';
|
import { startEntityWatcher } from '../src/server/fsWatch.js';
|
||||||
import { createTask, claimTask, listTasks } from '../src/core/services/taskService.js';
|
import { createTask, claimTask } from '../src/core/services/taskService.js';
|
||||||
import { listHubEventsAfter } from '../src/server/eventLog.js';
|
|
||||||
|
|
||||||
// ─── 1. Unit: SSE buffer parser ──────────────────────────────────────────────
|
// ─── 1. Unit: SSE buffer parser ──────────────────────────────────────────────
|
||||||
|
|
||||||
@ -75,19 +74,6 @@ describe('parseSSEBuffer', () => {
|
|||||||
const { events } = parseSSEBuffer(buf);
|
const { events } = parseSSEBuffer(buf);
|
||||||
expect(events).toHaveLength(0);
|
expect(events).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('attaches the SSE id field as event.seq', () => {
|
|
||||||
const buf = 'id: 42\ndata: {"type":"task","action":"created","id":"TSK-0042"}\n\n';
|
|
||||||
const { events } = parseSSEBuffer(buf);
|
|
||||||
expect(events).toHaveLength(1);
|
|
||||||
expect(events[0]).toMatchObject({ id: 'TSK-0042', seq: 42 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores named task-log events in the generic change parser', () => {
|
|
||||||
const buf = 'event: task-log\ndata: {"taskId":"TSK-0001","text":"progress"}\n\n';
|
|
||||||
const { events } = parseSSEBuffer(buf);
|
|
||||||
expect(events).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── 2. Unit: formatEvent ────────────────────────────────────────────────────
|
// ─── 2. Unit: formatEvent ────────────────────────────────────────────────────
|
||||||
@ -182,18 +168,15 @@ describe('eventBus mutations', () => {
|
|||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Bus task', role: 'implementer' } });
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Bus task', role: 'implementer' } });
|
||||||
expect(collected).toHaveLength(1);
|
expect(collected).toHaveLength(1);
|
||||||
expect(collected[0]).toMatchObject({ type: 'task', action: 'created', id: 'TSK-0001', title: 'Bus task', role: 'implementer' });
|
expect(collected[0]).toMatchObject({ type: 'task', action: 'created', id: 'TSK-0001', title: 'Bus task', role: 'implementer' });
|
||||||
expect(collected[0].seq).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits task/updated when PATCH /tasks/:id changes status', async () => {
|
it('emits task/updated when PATCH /tasks/:id changes status', async () => {
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
|
collected.length = 0; // clear the created event
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
|
||||||
collected.length = 0;
|
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
|
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
|
||||||
expect(collected).toHaveLength(1);
|
expect(collected).toHaveLength(1);
|
||||||
expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', id: 'TSK-0001', status: 'done' });
|
expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', id: 'TSK-0001', status: 'done' });
|
||||||
expect(collected[0].seq).toBe(4);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits task/updated with assignedTo when a task is claimed', async () => {
|
it('emits task/updated with assignedTo when a task is claimed', async () => {
|
||||||
@ -207,7 +190,6 @@ describe('eventBus mutations', () => {
|
|||||||
|
|
||||||
it('emits task/updated review when an implementer submits for review', async () => {
|
it('emits task/updated review when an implementer submits for review', async () => {
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
|
|
||||||
collected.length = 0;
|
collected.length = 0;
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
||||||
expect(collected).toHaveLength(1);
|
expect(collected).toHaveLength(1);
|
||||||
@ -216,7 +198,6 @@ describe('eventBus mutations', () => {
|
|||||||
|
|
||||||
it('emits task/updated open when the architect reopens after review', async () => {
|
it('emits task/updated open when the architect reopens after review', async () => {
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
|
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
||||||
collected.length = 0;
|
collected.length = 0;
|
||||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } });
|
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } });
|
||||||
@ -460,67 +441,6 @@ describe('SSE stream e2e', () => {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SSE durable replay', () => {
|
|
||||||
let cwd: string;
|
|
||||||
let server: Awaited<ReturnType<typeof startServer>>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-sse-replay-'));
|
|
||||||
init(cwd, { projectName: 'sse-replay-test', yes: true });
|
|
||||||
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await server.app.close();
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persists monotonic ids and replays missed events after Last-Event-ID', async () => {
|
|
||||||
await fetch(`${server.url}/tasks`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ title: 'first', role: 'implementer' }),
|
|
||||||
});
|
|
||||||
await fetch(`${server.url}/tasks`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ title: 'second', role: 'implementer' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(listHubEventsAfter(cwd, 0).map((e) => e.seq)).toEqual([1, 2]);
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const res = await fetch(`${server.url}/events`, {
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: { Accept: 'text/event-stream', 'Last-Event-ID': '1' },
|
|
||||||
});
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
expect(res.body).toBeTruthy();
|
|
||||||
|
|
||||||
const reader = res.body!.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = '';
|
|
||||||
const deadline = Date.now() + 1500;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
if (value) buffer += decoder.decode(value, { stream: true });
|
|
||||||
const parsed = parseSSEBuffer(buffer);
|
|
||||||
buffer = parsed.remaining;
|
|
||||||
if (parsed.events.length) {
|
|
||||||
await reader.cancel();
|
|
||||||
controller.abort();
|
|
||||||
expect(parsed.events).toHaveLength(1);
|
|
||||||
expect(parsed.events[0]).toMatchObject({ seq: 2, id: 'TSK-0002', title: 'second' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await reader.cancel().catch(() => undefined);
|
|
||||||
controller.abort();
|
|
||||||
throw new Error('Timed out waiting for replayed event');
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── 5. fsWatch: CLI / direct file writes also emit (TSK-0006 part 2) ─────────
|
// ─── 5. fsWatch: CLI / direct file writes also emit (TSK-0006 part 2) ─────────
|
||||||
// The whole point of the watcher: a `agenthub task create` (no --server) writes
|
// The whole point of the watcher: a `agenthub task create` (no --server) writes
|
||||||
// the entity file directly, bypassing the REST emit path — yet a connected
|
// the entity file directly, bypassing the REST emit path — yet a connected
|
||||||
@ -563,7 +483,6 @@ describe('fsWatch emits for non-REST (CLI/file) writes', () => {
|
|||||||
stop();
|
stop();
|
||||||
eventBus.off('change', onChange);
|
eventBus.off('change', onChange);
|
||||||
collected.length = 0;
|
collected.length = 0;
|
||||||
resetFsWatchErrors();
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -590,37 +509,6 @@ describe('fsWatch emits for non-REST (CLI/file) writes', () => {
|
|||||||
expect(ev).toBeDefined();
|
expect(ev).toBeDefined();
|
||||||
expect(ev).toMatchObject({ type: 'task', action: 'updated', status: 'in_progress', assignedTo: 'windows-claude' });
|
expect(ev).toMatchObject({ type: 'task', action: 'updated', status: 'in_progress', assignedTo: 'windows-claude' });
|
||||||
}, 4000);
|
}, 4000);
|
||||||
|
|
||||||
it('reindexes every settled direct task-file edit', async () => {
|
|
||||||
const task = createTask(cwd, { title: 'manual edit', role: 'implementer' });
|
|
||||||
const file = join(cwd, '.agenthub', 'tasks', `${task.id}.md`);
|
|
||||||
await waitFor(collected, (e) => e.id === task.id);
|
|
||||||
|
|
||||||
for (const status of ['in_progress', 'review']) {
|
|
||||||
const next = readFileSync(file, 'utf-8')
|
|
||||||
.replace(/^status: .*$/m, `status: ${status}`)
|
|
||||||
.replace(/^updatedAt: .*$/m, `updatedAt: ${new Date().toISOString()}`);
|
|
||||||
writeFileSync(file, next);
|
|
||||||
const deadline = Date.now() + 2000;
|
|
||||||
while (Date.now() < deadline && listTasks(cwd).find((t) => t.id === task.id)?.status !== status) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
||||||
}
|
|
||||||
expect(listTasks(cwd).find((t) => t.id === task.id)?.status).toBe(status);
|
|
||||||
}
|
|
||||||
}, 6000);
|
|
||||||
|
|
||||||
it('retains the last good index row and exposes invalid frontmatter', async () => {
|
|
||||||
const task = createTask(cwd, { title: 'invalid edit', role: 'implementer' });
|
|
||||||
const file = join(cwd, '.agenthub', 'tasks', `${task.id}.md`);
|
|
||||||
await waitFor(collected, (e) => e.id === task.id);
|
|
||||||
writeFileSync(file, readFileSync(file, 'utf-8').replace(/^status: .*$/m, 'status: definitely_invalid'));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
expect(listTasks(cwd).find((t) => t.id === task.id)?.status).toBe('open');
|
|
||||||
expect(getFsWatchErrors()).toEqual([
|
|
||||||
expect.objectContaining({ filePath: file, error: expect.stringMatching(/status/i) }),
|
|
||||||
]);
|
|
||||||
}, 4000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── 6. fsWatch + REST dedup: each change is delivered exactly once ────────────
|
// ─── 6. fsWatch + REST dedup: each change is delivered exactly once ────────────
|
||||||
|
|||||||
@ -4,11 +4,10 @@ import { tmpdir } from 'os';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import {
|
import {
|
||||||
createTask, listTasks, getTask,
|
createTask, listTasks, getTask,
|
||||||
claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask, recordExternalTask, dispatchTask,
|
claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask,
|
||||||
} from '../src/core/services/taskService.js';
|
} from '../src/core/services/taskService.js';
|
||||||
import { init } from '../src/cli/commands/init.js';
|
import { init } from '../src/cli/commands/init.js';
|
||||||
import { Index } from '../src/core/index.js';
|
import { Index } from '../src/core/index.js';
|
||||||
import { loadConfig, saveConfig } from '../src/core/config.js';
|
|
||||||
|
|
||||||
describe('taskService', () => {
|
describe('taskService', () => {
|
||||||
let cwd: string;
|
let cwd: string;
|
||||||
@ -40,15 +39,12 @@ describe('taskService', () => {
|
|||||||
expect(claimed.assignedTo).toBe('codex');
|
expect(claimed.assignedTo).toBe('codex');
|
||||||
expect(claimed.claimedBy).toBe('codex');
|
expect(claimed.claimedBy).toBe('codex');
|
||||||
expect(listTasks(cwd, { status: 'in_progress' })[0]).toMatchObject({ assignedTo: 'codex', claimedBy: 'codex' });
|
expect(listTasks(cwd, { status: 'in_progress' })[0]).toMatchObject({ assignedTo: 'codex', claimedBy: 'codex' });
|
||||||
reviewTask(cwd, task.id);
|
|
||||||
const done = doneTask(cwd, task.id);
|
const done = doneTask(cwd, task.id);
|
||||||
expect(done.status).toBe('done');
|
expect(done.status).toBe('done');
|
||||||
expect(done.claimedBy).toBe('codex');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('transitions a task to review', () => {
|
it('transitions a task to review', () => {
|
||||||
const task = createTask(cwd, { title: 'C', role: 'implementer' });
|
const task = createTask(cwd, { title: 'C', role: 'implementer' });
|
||||||
claimTask(cwd, task.id, 'codex');
|
|
||||||
const inReview = reviewTask(cwd, task.id);
|
const inReview = reviewTask(cwd, task.id);
|
||||||
expect(inReview.status).toBe('review');
|
expect(inReview.status).toBe('review');
|
||||||
// Verify index is updated
|
// Verify index is updated
|
||||||
@ -59,7 +55,6 @@ describe('taskService', () => {
|
|||||||
|
|
||||||
it('records an explicit reviewer separately from the assignee', () => {
|
it('records an explicit reviewer separately from the assignee', () => {
|
||||||
const task = createTask(cwd, { title: 'Review me', role: 'implementer', assignedTo: 'codex' });
|
const task = createTask(cwd, { title: 'Review me', role: 'implementer', assignedTo: 'codex' });
|
||||||
claimTask(cwd, task.id, 'codex');
|
|
||||||
const inReview = reviewTask(cwd, task.id, 'claude');
|
const inReview = reviewTask(cwd, task.id, 'claude');
|
||||||
expect(inReview.status).toBe('review');
|
expect(inReview.status).toBe('review');
|
||||||
expect(inReview.assignedTo).toBe('codex');
|
expect(inReview.assignedTo).toBe('codex');
|
||||||
@ -73,7 +68,6 @@ describe('taskService', () => {
|
|||||||
it('uses the preferred reviewer when reviewTask is called without one', () => {
|
it('uses the preferred reviewer when reviewTask is called without one', () => {
|
||||||
init(cwd, { projectName: 'reviewer-test', yes: true });
|
init(cwd, { projectName: 'reviewer-test', yes: true });
|
||||||
const task = createTask(cwd, { title: 'Review default', role: 'implementer', assignedTo: 'codex' });
|
const task = createTask(cwd, { title: 'Review default', role: 'implementer', assignedTo: 'codex' });
|
||||||
claimTask(cwd, task.id, 'codex');
|
|
||||||
const inReview = reviewTask(cwd, task.id);
|
const inReview = reviewTask(cwd, task.id);
|
||||||
expect(inReview.reviewer).toBe('claude');
|
expect(inReview.reviewer).toBe('claude');
|
||||||
});
|
});
|
||||||
@ -87,13 +81,10 @@ describe('taskService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('reopens a task (any status → open)', () => {
|
it('reopens a task (any status → open)', () => {
|
||||||
const task = createTask(cwd, { title: 'E', role: 'implementer', assignedTo: 'kimi' });
|
const task = createTask(cwd, { title: 'E', role: 'implementer' });
|
||||||
claimTask(cwd, task.id, 'kimi');
|
cancelTask(cwd, task.id);
|
||||||
reviewTask(cwd, task.id);
|
|
||||||
const reopened = reopenTask(cwd, task.id);
|
const reopened = reopenTask(cwd, task.id);
|
||||||
expect(reopened.status).toBe('open');
|
expect(reopened.status).toBe('open');
|
||||||
expect(reopened.assignedTo).toBe('kimi');
|
|
||||||
expect(reopened.claimedBy).toBeUndefined();
|
|
||||||
const listed = listTasks(cwd, { status: 'open' });
|
const listed = listTasks(cwd, { status: 'open' });
|
||||||
expect(listed).toHaveLength(1);
|
expect(listed).toHaveLength(1);
|
||||||
});
|
});
|
||||||
@ -131,23 +122,4 @@ describe('taskService', () => {
|
|||||||
it('deleteTask is idempotent (deleting a missing task does not throw)', () => {
|
it('deleteTask is idempotent (deleting a missing task does not throw)', () => {
|
||||||
expect(() => deleteTask(cwd, 'TSK-9999')).not.toThrow();
|
expect(() => deleteTask(cwd, 'TSK-9999')).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dispatches a session-less agent explicitly', () => {
|
|
||||||
init(cwd, { projectName: 'dispatch-test', yes: true });
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
config.agents = { ...(config.agents ?? {}), backyard: { role: 'implementer', dispatch: 'architect' } };
|
|
||||||
saveConfig(cwd, config);
|
|
||||||
const task = createTask(cwd, { title: 'Backend', assignedTo: 'backyard' });
|
|
||||||
expect(dispatchTask(cwd, task.id, 'backyard')).toMatchObject({
|
|
||||||
status: 'in_progress', assignedTo: 'backyard', claimedBy: 'backyard',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('records external work directly as a provenance-marked done task', () => {
|
|
||||||
init(cwd, { projectName: 'record-test', yes: true });
|
|
||||||
const task = recordExternalTask(cwd, { title: 'Already shipped', doneBy: 'codex' });
|
|
||||||
expect(task).toMatchObject({ status: 'done', origin: 'external', doneBy: 'codex', claimedBy: 'codex' });
|
|
||||||
expect(task.recordedAt).toBeTruthy();
|
|
||||||
expect(getTask(cwd, task.id).body).toContain('Nachgetragen');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,198 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
||||||
import { mkdtempSync, rmSync } from 'fs';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { init } from '../src/cli/commands/init.js';
|
|
||||||
import { loadConfig, saveConfig } from '../src/core/config.js';
|
|
||||||
import { createTask, claimTask, getTask, reviewTask } from '../src/core/services/taskService.js';
|
|
||||||
import { listMessages } from '../src/core/services/messageService.js';
|
|
||||||
import { readTaskLog, appendTaskLog } from '../src/core/services/taskLogService.js';
|
|
||||||
import { startWatchdog, resetWatchdog } from '../src/server/watchdog.js';
|
|
||||||
import { eventBus, type AgentHubEvent } from '../src/server/events.js';
|
|
||||||
import { enterLoop, leaveLoop, resetPresence } from '../src/core/services/presenceService.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Watchdog thresholds (TSK-0226). Fake timers drive both the scan interval and
|
|
||||||
* the task ages (Date is mocked, so createdAt/updatedAt advance with the clock).
|
|
||||||
* Thresholds are shrunk via the `watchdog` config section to keep the test fast.
|
|
||||||
*/
|
|
||||||
describe('watchdog', () => {
|
|
||||||
let cwd: string;
|
|
||||||
let stop: (() => void) | undefined;
|
|
||||||
let events: AgentHubEvent[];
|
|
||||||
const onChange = (e: AgentHubEvent) => events.push(e);
|
|
||||||
|
|
||||||
// Small thresholds: interval 1s, high-prio unclaimed 2s, default 5s, stale 4s.
|
|
||||||
const INTERVAL = 1_000;
|
|
||||||
const HIGH = 2_000;
|
|
||||||
const DEFAULT = 5_000;
|
|
||||||
const STALE = 4_000;
|
|
||||||
const REVIEW = 3_000;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-watchdog-'));
|
|
||||||
init(cwd, { projectName: 'watchdog-test', yes: true });
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
config.watchdog = {
|
|
||||||
enabled: true,
|
|
||||||
intervalMs: INTERVAL,
|
|
||||||
unclaimedHighMs: HIGH,
|
|
||||||
unclaimedDefaultMs: DEFAULT,
|
|
||||||
staleInProgressMs: STALE,
|
|
||||||
staleReviewMs: REVIEW,
|
|
||||||
};
|
|
||||||
saveConfig(cwd, config);
|
|
||||||
resetWatchdog();
|
|
||||||
resetPresence();
|
|
||||||
events = [];
|
|
||||||
eventBus.on('change', onChange);
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
stop?.();
|
|
||||||
stop = undefined;
|
|
||||||
eventBus.off('change', onChange);
|
|
||||||
vi.useRealTimers();
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const remindersFor = (agent: string) =>
|
|
||||||
listMessages(cwd).filter((m) => m.to === agent && m.text.startsWith('Reminder:'));
|
|
||||||
|
|
||||||
it('re-notifies an unclaimed high-priority task: SSE re-emit + unread reminder + warn log', async () => {
|
|
||||||
createTask(cwd, { title: 'Urgent fix', priority: 'high', assignedTo: 'codex' });
|
|
||||||
stop = startWatchdog(cwd);
|
|
||||||
|
|
||||||
// Below the threshold: nothing happens.
|
|
||||||
await vi.advanceTimersByTimeAsync(INTERVAL);
|
|
||||||
expect(remindersFor('codex')).toHaveLength(0);
|
|
||||||
|
|
||||||
// Past the 2s high-priority threshold (next tick at t=2s fires the alert).
|
|
||||||
await vi.advanceTimersByTimeAsync(INTERVAL + 100);
|
|
||||||
|
|
||||||
// 1. Reminder message lands as UNREAD so the assignee's work loop wakes.
|
|
||||||
const reminders = remindersFor('codex');
|
|
||||||
expect(reminders).toHaveLength(1);
|
|
||||||
expect(reminders[0].status).toBe('unread');
|
|
||||||
expect(reminders[0].text).toBe('Reminder: TSK-0001 wartet auf dich');
|
|
||||||
expect(reminders[0].taskId).toBe('TSK-0001');
|
|
||||||
|
|
||||||
// 2. The task event is re-emitted on the bus (SSE fan-out).
|
|
||||||
expect(events.some((e) => e.type === 'task' && e.id === 'TSK-0001' && e.assignedTo === 'codex')).toBe(true);
|
|
||||||
|
|
||||||
// 3. Board-visible warn line on the task's live console.
|
|
||||||
const log = readTaskLog(cwd, 'TSK-0001');
|
|
||||||
expect(log.some((l) => l.level === 'warn' && l.text.includes('Watchdog'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not spam: re-alerts only after the threshold elapses again', async () => {
|
|
||||||
createTask(cwd, { title: 'Urgent fix', priority: 'critical', assignedTo: 'codex' });
|
|
||||||
stop = startWatchdog(cwd);
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(HIGH + 100); // first alert at t≈2s
|
|
||||||
expect(remindersFor('codex')).toHaveLength(1);
|
|
||||||
|
|
||||||
// One more interval tick — still inside the cooldown window.
|
|
||||||
await vi.advanceTimersByTimeAsync(INTERVAL);
|
|
||||||
expect(remindersFor('codex')).toHaveLength(1);
|
|
||||||
|
|
||||||
// Threshold elapsed again since the last alert → second reminder.
|
|
||||||
await vi.advanceTimersByTimeAsync(HIGH);
|
|
||||||
expect(remindersFor('codex')).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the longer threshold for medium/low priority tasks', async () => {
|
|
||||||
createTask(cwd, { title: 'Routine', priority: 'medium', assignedTo: 'kimi' });
|
|
||||||
stop = startWatchdog(cwd);
|
|
||||||
|
|
||||||
// Past the high-priority threshold but below the default one: no alert.
|
|
||||||
await vi.advanceTimersByTimeAsync(HIGH + INTERVAL);
|
|
||||||
expect(remindersFor('kimi')).toHaveLength(0);
|
|
||||||
|
|
||||||
// Past the 5s default threshold.
|
|
||||||
await vi.advanceTimersByTimeAsync(DEFAULT);
|
|
||||||
expect(remindersFor('kimi')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('alerts the architect when an assigned task targets an agent known to be out of the work loop', async () => {
|
|
||||||
createTask(cwd, { title: 'Unheard task', priority: 'high', assignedTo: 'codex' });
|
|
||||||
enterLoop('codex');
|
|
||||||
leaveLoop('codex', 'client timeout');
|
|
||||||
stop = startWatchdog(cwd);
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(HIGH + 100);
|
|
||||||
const alerts = listMessages(cwd).filter(
|
|
||||||
(m) => m.to === 'claude' && m.text.includes('not in agenthub_work'),
|
|
||||||
);
|
|
||||||
expect(alerts).toHaveLength(1);
|
|
||||||
expect(alerts[0].taskId).toBe('TSK-0001');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('routes architect-dispatched agents to the architect once and never re-notifies the agent', async () => {
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
config.agents = {
|
|
||||||
...(config.agents ?? {}),
|
|
||||||
claude: { role: 'architect', dispatch: 'loop' },
|
|
||||||
backyard: { role: 'implementer', dispatch: 'architect' },
|
|
||||||
};
|
|
||||||
saveConfig(cwd, config);
|
|
||||||
createTask(cwd, { title: 'Backend task', priority: 'high', assignedTo: 'backyard' });
|
|
||||||
stop = startWatchdog(cwd);
|
|
||||||
await vi.advanceTimersByTimeAsync(HIGH + 100);
|
|
||||||
expect(remindersFor('backyard')).toHaveLength(0);
|
|
||||||
const notices = () => listMessages(cwd).filter((m) => m.to === 'claude' && m.text.includes('wird von dir gestartet'));
|
|
||||||
expect(notices()).toHaveLength(1);
|
|
||||||
await vi.advanceTimersByTimeAsync(HIGH * 2);
|
|
||||||
expect(notices()).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('alerts the architect about a silent in_progress task — without reassigning', async () => {
|
|
||||||
createTask(cwd, { title: 'WIP', priority: 'high', assignedTo: 'kimi' });
|
|
||||||
claimTask(cwd, 'TSK-0001', 'kimi');
|
|
||||||
stop = startWatchdog(cwd);
|
|
||||||
|
|
||||||
// No task-log lines at all; silent past the stale threshold.
|
|
||||||
await vi.advanceTimersByTimeAsync(STALE + INTERVAL);
|
|
||||||
|
|
||||||
// Architect (init default: claude) got the alert; the assignee got none.
|
|
||||||
const architectAlerts = listMessages(cwd).filter((m) => m.to === 'claude' && m.text.startsWith('Watchdog:'));
|
|
||||||
expect(architectAlerts).toHaveLength(1);
|
|
||||||
expect(architectAlerts[0].status).toBe('unread');
|
|
||||||
expect(architectAlerts[0].text).toContain('TSK-0001');
|
|
||||||
expect(remindersFor('kimi')).toHaveLength(0);
|
|
||||||
|
|
||||||
// Visibility only: the task still belongs to kimi (no auto-reassign).
|
|
||||||
const { task } = getTask(cwd, 'TSK-0001');
|
|
||||||
expect(task.status).toBe('in_progress');
|
|
||||||
expect(task.assignedTo).toBe('kimi');
|
|
||||||
expect(task.claimedBy).toBe('kimi');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a fresh task-log line keeps an in_progress task from being flagged stale', async () => {
|
|
||||||
createTask(cwd, { title: 'Chatty WIP', priority: 'high', assignedTo: 'kimi' });
|
|
||||||
claimTask(cwd, 'TSK-0001', 'kimi');
|
|
||||||
stop = startWatchdog(cwd);
|
|
||||||
|
|
||||||
// Log activity well inside the stale window, repeatedly: never flagged.
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
await vi.advanceTimersByTimeAsync(STALE - 2 * INTERVAL); // 2s < 4s stale window
|
|
||||||
appendTaskLog(cwd, 'TSK-0001', { text: `progress ${i}`, agent: 'kimi' });
|
|
||||||
}
|
|
||||||
expect(listMessages(cwd).filter((m) => m.text.startsWith('Watchdog:'))).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('alerts the architect when a review waits too long', async () => {
|
|
||||||
createTask(cwd, { title: 'Review me', assignedTo: 'kimi' });
|
|
||||||
claimTask(cwd, 'TSK-0001', 'kimi');
|
|
||||||
reviewTask(cwd, 'TSK-0001');
|
|
||||||
stop = startWatchdog(cwd);
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(REVIEW + INTERVAL);
|
|
||||||
const alerts = listMessages(cwd).filter(
|
|
||||||
(m) => m.to === 'claude' && m.text.includes('waited in review'),
|
|
||||||
);
|
|
||||||
expect(alerts).toHaveLength(1);
|
|
||||||
expect(alerts[0].taskId).toBe('TSK-0001');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -131,14 +131,12 @@ describe('agenthub work — wait then auto-claim (server SSE)', () => {
|
|||||||
|
|
||||||
await workDone; // resolves because the message woke the loop (not on timeout)
|
await workDone; // resolves because the message woke the loop (not on timeout)
|
||||||
|
|
||||||
// The loop drained + surfaced the message (unread → delivered) — proving it
|
// The loop drained + marked the message read — proving it woke on the message,
|
||||||
// woke on the message, not that it merely timed out (a timeout would leave
|
// not that it merely timed out (a timeout would leave it delivered/unread).
|
||||||
// it unread). Since TSK-0224 the loop no longer auto-marks read: the message
|
|
||||||
// stays visible in the inbox until an explicit ack/read.
|
|
||||||
const inbox = (await fetch(`${server.url}/messages?agent=kimi`).then((r) => r.json())) as Array<{ text: string; status: string }>;
|
const inbox = (await fetch(`${server.url}/messages?agent=kimi`).then((r) => r.json())) as Array<{ text: string; status: string }>;
|
||||||
const m = inbox.find((x) => x.text.includes('quick question'));
|
const m = inbox.find((x) => x.text.includes('quick question'));
|
||||||
expect(m).toBeDefined();
|
expect(m).toBeDefined();
|
||||||
expect(m?.status).toBe('delivered');
|
expect(m?.status).toBe('read');
|
||||||
}, 7000);
|
}, 7000);
|
||||||
|
|
||||||
it('wakes and re-claims when a submitted task is reopened', async () => {
|
it('wakes and re-claims when a submitted task is reopened', async () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user