Compare commits
27 Commits
c9e240b068
...
df698c9dff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df698c9dff | ||
|
|
908a043ac5 | ||
|
|
8a1728cbce | ||
|
|
dac8afb3c1 | ||
|
|
6e614b1f4d | ||
|
|
178883cd2e | ||
|
|
55606eddc8 | ||
|
|
dbaf29a6c6 | ||
|
|
e8c2e43cb0 | ||
|
|
e5bd28488d | ||
|
|
6a4cd32312 | ||
|
|
c2e19b3371 | ||
|
|
7a4ccaeb61 | ||
|
|
5fb2b6d6c6 | ||
|
|
1250359e6d | ||
|
|
4be0826a6f | ||
|
|
a6f640242a | ||
|
|
e2bbae2044 | ||
|
|
b5533359d5 | ||
|
|
1f3126b3b5 | ||
|
|
6d088648da | ||
|
|
9dfbf3f657 | ||
|
|
f5680c6982 | ||
|
|
7bf52a0be3 | ||
|
|
9551c90597 | ||
|
|
52e5d0bf28 | ||
|
|
97db2358bb |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "0.10.0",
|
"version": "0.10.2",
|
||||||
"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,15 +2,20 @@ 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. That text is
|
* `agenthub hook-context`, which prints the work-loop instruction. Codex also
|
||||||
* injected into the new session as context. We write the hook to the RIGHT place
|
* gets a `Stop` hook that waits in `agenthub work` and uses Codex's native
|
||||||
* per CLI:
|
* continuation decision to wake the same thread. We write hooks to the RIGHT
|
||||||
|
* 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)
|
||||||
@ -37,16 +42,86 @@ 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;
|
||||||
process.stdout.write(
|
// Denselben Text wie `agenthub start` ausgeben. Der Hook ist fuer viele
|
||||||
`AgentHub auto-start — you are "${agent}" (${role}). Before anything else, enter the work loop:\n` +
|
// Sessions der EINZIGE Einstieg (Codex/Kimi starten automatisch) — ein
|
||||||
` agenthub work --agent ${agent} --role ${role}\n` +
|
// eigener, aelterer Text hier fuehrt dazu, dass auto-gestartete Agenten den
|
||||||
`(or call the agenthub_work tool with { agent: "${agent}", role: "${role}" }). It BLOCKS until a ` +
|
// Check-in-Kanal und DEC-0035 gar nicht kennen. Genau das war der Fall, bis
|
||||||
`task or message addressed to you arrives, then returns it. Handle it, then IMMEDIATELY call ` +
|
// der CEO bemerkte, dass codex ohne `agenthub start` losgelaufen ist.
|
||||||
`agenthub_work AGAIN — run it in the BACKGROUND so you're woken on the next event without ` +
|
process.stdout.write(`${agentBriefing(agent, role)}\n`);
|
||||||
`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) ───────────────────
|
||||||
@ -110,21 +185,36 @@ 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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupCodex(opts: { agent: string; role: string }, out: (s: string) => void): void {
|
export function codexHookBlock(agent: string, role: string): string {
|
||||||
const path = join(homedir(), '.codex', 'config.toml');
|
// Deliberately omit --role: the active role is resolved from AgentHub on
|
||||||
const command = `agenthub hook-context --agent ${opts.agent} --role ${opts.role}`;
|
// every hook run, so switching codex implementer ↔ architect takes effect
|
||||||
const block =
|
// in the next continuation without reinstalling the hook.
|
||||||
|
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(command)}\n` +
|
`command = ${JSON.stringify(startCommand)}\n` +
|
||||||
`timeout = 30\n` +
|
`timeout = 30\n` +
|
||||||
`statusMessage = "AgentHub auto-start (${opts.agent})"`;
|
`statusMessage = "AgentHub auto-start (${agent})"\n\n` +
|
||||||
|
`[[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}) and enter the work loop.`);
|
out(` New Codex TUI sessions start as "${opts.agent}" (${opts.role}).`);
|
||||||
out(' Codex fires SessionStart hooks from the global config in interactive sessions.');
|
out(' The Stop hook waits in agenthub work and resumes this thread natively when work arrives.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupKimi(opts: { agent: string; role: string }, out: (s: string) => void): void {
|
function setupKimi(opts: { agent: string; role: string }, out: (s: string) => void): void {
|
||||||
|
|||||||
49
src/cli/commands/health.ts
Normal file
49
src/cli/commands/health.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
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,4 +1,5 @@
|
|||||||
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.
|
||||||
@ -30,5 +31,22 @@ export async function serverStart(cwd: string, options: { port: number; host: st
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await startServer(cwd, options);
|
// Verzeichnis-Guard: NICHT den Port binden, wenn hier gar kein Projekt liegt.
|
||||||
|
// 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,6 +5,7 @@ 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;
|
||||||
@ -21,6 +22,8 @@ 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. */
|
||||||
@ -58,34 +61,88 @@ 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 open task addressed to this agent. "Addressed" = task title starts
|
* Find the ONE open task this agent should claim next. "Addressed" = task
|
||||||
* with "<agent>:" (delegation convention), OR a handoff for the task has
|
* title starts with "<agent>:" (delegation convention), OR a handoff for the
|
||||||
* toAgent === <agent>, OR the task is already assignedTo this agent (a reopened
|
* task has toAgent === <agent>, OR the task is already assignedTo this agent
|
||||||
* task that came back for rework). Returns the task + the handoff list (so the
|
* (a reopened task that came back for rework).
|
||||||
* 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 a = ctx.agent.toLowerCase();
|
const identity = ctx.serverUrl
|
||||||
|
? 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 && String(h.toAgent).toLowerCase() === a && h.taskId)
|
.filter((h) => h.toAgent && matchesAgent(h.toAgent) && 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 && String(t.assignedTo).toLowerCase() === a),
|
(t.assignedTo && matchesAgent(t.assignedTo)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mine.length === 0) return undefined;
|
if (mine.length === 0) return undefined;
|
||||||
return { task: mine[0], handoffs };
|
if (mine.length === 1) 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. */
|
||||||
@ -104,11 +161,23 @@ export async function claimAndPrintTask(ctx: AgentContext, task: Listed, handoff
|
|||||||
/* body is optional */
|
/* body is optional */
|
||||||
}
|
}
|
||||||
|
|
||||||
const hof = handoffs.find((h) => h.taskId === task.id);
|
// Pick the LATEST handoff for this task, not the first one created. When a
|
||||||
|
// 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 {
|
||||||
|
|||||||
252
src/cli/commands/startAgent.ts
Normal file
252
src/cli/commands/startAgent.ts
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
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, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js';
|
import { createTask, recordExternalTask, dispatchTask, 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,6 +42,14 @@ 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,21 +24,30 @@ 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.
|
||||||
* This function splits on double-newline boundaries, extracts the `data:`
|
* Frame separators are CRLF-tolerant: both "\n\n" and "\r\n\r\n" (and mixed
|
||||||
* line from each complete block, and returns anything that didn't end with
|
* line endings inside a frame) are accepted. An event with multiple `data:`
|
||||||
* "\n\n" as `remaining` (to be prepended to the next chunk).
|
* lines is joined with "\n" before parsing, per the SSE spec.
|
||||||
|
* 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('\n\n');
|
const parts = buffer.split(/\r?\n\r?\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 dataLine = part.split('\n').find((l) => l.startsWith('data: '));
|
const lines = part.split(/\r?\n/);
|
||||||
if (!dataLine) continue;
|
const eventName = lines.find((l) => l.startsWith('event: '))?.slice(7);
|
||||||
|
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 {
|
||||||
events.push(JSON.parse(dataLine.slice(6)) as AgentHubEvent);
|
const event = JSON.parse(dataLines.join('\n')) 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.
|
||||||
}
|
}
|
||||||
@ -159,10 +168,16 @@ async function fetchUnreadMessages(serverUrl: string, agent: string): Promise<Ag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMessageFor(event: AgentHubEvent, agent: string): boolean {
|
function isMessageFor(event: AgentHubEvent, agent: string, ignoreFrom: 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;
|
||||||
return !!to && messageRecipientAliases(agent).has(String(to).toLowerCase());
|
if (!to || !messageRecipientAliases(agent).has(String(to).toLowerCase())) return false;
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -179,7 +194,7 @@ function isMessageFor(event: AgentHubEvent, agent: string): boolean {
|
|||||||
*/
|
*/
|
||||||
export async function watchEvents(
|
export async function watchEvents(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean } = {},
|
options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean; ignoreFrom?: string[] } = {},
|
||||||
): 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
|
||||||
@ -187,6 +202,7 @@ 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' },
|
||||||
@ -250,6 +266,7 @@ 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) {
|
||||||
@ -269,7 +286,7 @@ export async function watchEvents(
|
|||||||
await reader.cancel();
|
await reader.cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.awaitMessage && isMessageFor(event, options.awaitMessage)) {
|
if (options.awaitMessage && isMessageFor(event, options.awaitMessage, options.ignoreFrom)) {
|
||||||
await reader.cancel();
|
await reader.cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { parseSSEBuffer } from './watch.js';
|
import { parseSSEBuffer } from './watch.js';
|
||||||
import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.js';
|
import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.js';
|
||||||
|
import { resolvePending } from '../../core/services/checkinService.js';
|
||||||
import { discoverServer as discoverHubServer } from '../../discovery.js';
|
import { discoverServer as discoverHubServer } from '../../discovery.js';
|
||||||
import { remoteClient } from '../remoteClient.js';
|
import { remoteClient } from '../remoteClient.js';
|
||||||
|
|
||||||
@ -19,6 +20,8 @@ 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
|
||||||
@ -28,11 +31,17 @@ interface WorkAgentContext extends AgentContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch + print + mark-read the agent's unread messages. Returns how many were
|
* Fetch + print the agent's unread messages. Returns how many were surfaced.
|
||||||
* surfaced. This is what lets the work loop wake on an architect follow-up /
|
* This is what lets the work loop wake on an architect follow-up / question
|
||||||
* question (TSK-0119): after a `task review` submit the implementer re-arms
|
* (TSK-0119): after a `task review` submit the implementer re-arms `work` and
|
||||||
* `work` and stays reachable — a reopen or a new assignment wakes it via a task
|
* stays reachable — a reopen or a new assignment wakes it via a task event,
|
||||||
* event, and a plain message wakes it here instead of leaving it dormant.
|
* 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;
|
||||||
@ -46,11 +55,6 @@ 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;
|
||||||
}
|
}
|
||||||
@ -58,6 +62,21 @@ async function drainAgentMessages(ctx: WorkAgentContext): Promise<number> {
|
|||||||
export async function workAgent(ctx: WorkAgentContext): Promise<void> {
|
export async function workAgent(ctx: WorkAgentContext): Promise<void> {
|
||||||
await announceAgent(ctx.serverUrl, ctx.agent, ctx.role);
|
await announceAgent(ctx.serverUrl, ctx.agent, ctx.role);
|
||||||
|
|
||||||
|
// Haelt der Agent schon eine Task? Der Finder unten sucht nur `open` — nach
|
||||||
|
// einem Session-Neustart wartet der Worker deshalb auf neue Arbeit, waehrend
|
||||||
|
// die eigene, bereits geclaimte Task unbearbeitet liegt. Das war ein echter
|
||||||
|
// Stillstand: Task auf in_progress, Agent tut nichts, niemand sieht warum.
|
||||||
|
try {
|
||||||
|
const held = resolvePending(ctx.projectCwd, ctx.agent).heldTask;
|
||||||
|
if (held) {
|
||||||
|
console.log(
|
||||||
|
`AgentHub: du haeltst bereits ${held}. Arbeite dort weiter und reiche sie mit `
|
||||||
|
+ `\`agenthub task review ${held}\` ein — erst danach kommt Neues.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* ohne Projekt-Kontext weiter wie bisher */ }
|
||||||
|
|
||||||
// Already-waiting task?
|
// Already-waiting task?
|
||||||
const found = await findAddressedOpenTask(ctx);
|
const found = await findAddressedOpenTask(ctx);
|
||||||
if (found) {
|
if (found) {
|
||||||
@ -102,9 +121,11 @@ 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 {
|
||||||
@ -122,36 +143,74 @@ 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> => {
|
||||||
ctx.serverUrl = serverUrl;
|
if (checking) return false;
|
||||||
const f = await findAddressedOpenTask(ctx);
|
checking = true;
|
||||||
if (!f) return false;
|
try {
|
||||||
if (timer) clearTimeout(timer);
|
ctx.serverUrl = serverUrl;
|
||||||
await claimAndPrintTask(ctx, f.task, f.handoffs);
|
const f = await findAddressedOpenTask(ctx);
|
||||||
finish();
|
if (!f) return false;
|
||||||
return true;
|
if (timer) clearTimeout(timer);
|
||||||
|
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> => {
|
||||||
ctx.serverUrl = serverUrl;
|
if (checking) return false;
|
||||||
const n = await drainAgentMessages(ctx);
|
checking = true;
|
||||||
if (n === 0) return false;
|
try {
|
||||||
if (timer) clearTimeout(timer);
|
ctx.serverUrl = serverUrl;
|
||||||
finish();
|
const n = await drainAgentMessages(ctx);
|
||||||
return true;
|
if (n === 0) return false;
|
||||||
|
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`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } });
|
const res = await fetch(`${serverUrl}/events`, {
|
||||||
|
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
|
||||||
@ -176,6 +235,9 @@ 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, taskDone, taskReview, taskReopen, taskAssign, taskLog } from './commands/task.js';
|
import { taskCreate, taskList, taskShow, taskClaim, taskDispatch, taskRecord, 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, hookContext } from './commands/agentSetup.js';
|
import { agentSetup, codexStopHook, 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,12 +15,16 @@ 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 } from '../discovery.js';
|
import { discoverServer, resolveReachableServerUrl } 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;
|
||||||
@ -47,6 +51,36 @@ 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;
|
||||||
@ -61,11 +95,29 @@ async function resolveContext(program: Command, cwd: string): Promise<ResolvedCo
|
|||||||
} catch {
|
} catch {
|
||||||
return { projectCwd: root };
|
return { projectCwd: root };
|
||||||
}
|
}
|
||||||
if (config.serverUrl) return { serverUrl: config.serverUrl, projectCwd: root };
|
if (config.serverUrl) {
|
||||||
|
// 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 discoverServer(2000);
|
const discovered = await preferLoopback(await discoverServer(2000));
|
||||||
if (discovered) {
|
if (discovered) {
|
||||||
try {
|
try {
|
||||||
saveConfig(root, { ...config, serverUrl: discovered });
|
saveConfig(root, { ...config, serverUrl: discovered });
|
||||||
@ -120,7 +172,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('0.9.1')
|
.version(VERSION)
|
||||||
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
||||||
|
|
||||||
program
|
program
|
||||||
@ -149,6 +201,21 @@ 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')
|
||||||
@ -301,6 +368,37 @@ 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')
|
||||||
@ -699,7 +797,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: write a SessionStart hook (Claude Code / Codex / Kimi) so sessions enter the work loop')
|
.description('Auto-start: install lifecycle hooks; Codex gets a native Stop-hook work-loop wake adapter')
|
||||||
.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)')
|
||||||
@ -709,6 +807,44 @@ 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
|
||||||
@ -731,9 +867,47 @@ 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')
|
||||||
.requiredOption('--role <role>', 'Agent role')
|
.option('--role <role>', 'Agent role override (normally resolved dynamically)')
|
||||||
.action((options: { agent: string; role: string }) => {
|
.action(async (options: { agent: string; role?: string }) => {
|
||||||
hookContext({ agent: options.agent, role: options.role });
|
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';
|
||||||
|
}
|
||||||
|
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
|
||||||
@ -771,12 +945,13 @@ 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')
|
||||||
.action(async (action: string | undefined, options: { print?: boolean }) => {
|
.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; agent?: string }) => {
|
||||||
if (action === 'install') {
|
if (action === 'install') {
|
||||||
installMcp(cwd, { print: options.print });
|
installMcp(cwd, { print: options.print, agent: options.agent });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await startMcpServer(cwd);
|
await startMcpServer(cwd, { agent: options.agent });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── hello (presence) ──────────────────────────────────────────────────────
|
// ─── hello (presence) ──────────────────────────────────────────────────────
|
||||||
@ -800,17 +975,35 @@ 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')
|
.command('start [agent]')
|
||||||
.description('Onboard: announce, claim the task addressed to you, print it + its handoff')
|
.description('Start an agent session: briefing + announce + claim the task addressed to you')
|
||||||
.requiredOption('--agent <name>', 'Agent name')
|
.option('--agent <name>', 'Agent name (alternative to the positional argument)')
|
||||||
.option('--role <role>', 'Role (default: implementer)', 'implementer')
|
.option('--role <role>', 'Role (default: from the roster, else implementer)')
|
||||||
.action(async (options) => {
|
.option('--no-briefing', 'Skip the standing briefing (onboarding output only)')
|
||||||
|
.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: options.agent, role: options.role }));
|
await runRemote(serverUrl, () => startAgent({ serverUrl, projectCwd, agent, role }));
|
||||||
} else {
|
} else {
|
||||||
await startAgent({ projectCwd, agent: options.agent, role: options.role });
|
await startAgent({ projectCwd, agent, role });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -842,6 +1035,7 @@ 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) {
|
||||||
@ -854,6 +1048,7 @@ 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,6 +2,7 @@ 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) {
|
||||||
@ -38,10 +39,61 @@ 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);
|
||||||
},
|
},
|
||||||
@ -49,6 +101,9 @@ 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>);
|
||||||
@ -63,6 +118,9 @@ 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,
|
||||||
@ -101,6 +159,12 @@ 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, type Config } from './schema.js';
|
import { ConfigSchema, WatchdogConfigSchema, type Config } from './schema.js';
|
||||||
|
|
||||||
export function defaultConfig(projectName: string): Config {
|
export function defaultConfig(projectName: string): Config {
|
||||||
return {
|
return {
|
||||||
@ -15,6 +15,7 @@ export function defaultConfig(projectName: string): Config {
|
|||||||
tester: { preferredAgent: 'codex' },
|
tester: { preferredAgent: 'codex' },
|
||||||
},
|
},
|
||||||
serverUrl: undefined,
|
serverUrl: undefined,
|
||||||
|
watchdog: WatchdogConfigSchema.parse({}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ 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}$/),
|
||||||
@ -27,6 +28,9 @@ 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({
|
||||||
@ -154,6 +158,21 @@ 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" | … */
|
||||||
@ -184,9 +203,30 @@ 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),
|
||||||
@ -197,6 +237,8 @@ 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>;
|
||||||
@ -207,3 +249,4 @@ 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,4 +1,7 @@
|
|||||||
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -39,6 +42,14 @@ 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;
|
||||||
|
|
||||||
@ -121,6 +132,9 @@ 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 {
|
||||||
@ -153,6 +167,7 @@ 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);
|
||||||
}
|
}
|
||||||
@ -161,7 +176,8 @@ 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);
|
||||||
|
|
||||||
for (const t of listTasks(cwd)) {
|
const tasks = 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(
|
||||||
@ -183,6 +199,50 @@ 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);
|
||||||
@ -201,7 +261,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). 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) 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.`,
|
||||||
},
|
},
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
216
src/core/services/checkinService.ts
Normal file
216
src/core/services/checkinService.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
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,15 +4,17 @@ 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: options.toAgent,
|
toAgent,
|
||||||
taskId: options.taskId,
|
taskId: options.taskId,
|
||||||
summary: options.summary ?? 'Handoff',
|
summary: options.summary ?? 'Handoff',
|
||||||
context: options.context ?? '',
|
context: options.context ?? '',
|
||||||
|
|||||||
49
src/core/services/identityService.ts
Normal file
49
src/core/services/identityService.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
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,6 +4,7 @@ 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 {
|
||||||
@ -28,10 +29,11 @@ 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: options.to,
|
to,
|
||||||
text: options.text,
|
text: options.text,
|
||||||
taskId: options.taskId,
|
taskId: options.taskId,
|
||||||
replyTo: options.replyTo,
|
replyTo: options.replyTo,
|
||||||
@ -51,14 +53,17 @@ 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(agent: string): Set<string> {
|
export function messageRecipientAliases(cwdOrAgent: string, agent?: string): Set<string> {
|
||||||
const key = agent.toLowerCase();
|
if (agent) return agentIdentity(cwdOrAgent, agent).names;
|
||||||
const aliases = new Set([agent, key]);
|
// Remote event filters do not have project config; new writes are already
|
||||||
|
// 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') {
|
||||||
aliases.add('architect');
|
names.add('architect');
|
||||||
aliases.add('claude');
|
names.add('claude');
|
||||||
}
|
}
|
||||||
return aliases;
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InboxMessage {
|
export interface InboxMessage {
|
||||||
@ -78,15 +83,18 @@ 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
|
*
|
||||||
* and then marks read, so the delivered intermediate is invisible there — no
|
* Nothing auto-marks a message `read`: a delivered-but-unacked message stays
|
||||||
* regress and no spin.)
|
* visible in the default inbox view until the recipient explicitly acks/reads
|
||||||
|
* 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(agent);
|
const recipients = messageRecipientAliases(cwd, 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,6 +53,10 @@ 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
211
src/core/services/presenceService.ts
Normal file
211
src/core/services/presenceService.ts
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
51
src/core/services/roleService.ts
Normal file
51
src/core/services/roleService.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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,6 +10,7 @@ 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. */
|
||||||
@ -38,13 +39,22 @@ 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: a.role,
|
role: preferredRole.get(name) ?? 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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,6 +70,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) };
|
return { name, role: roleByAgent.get(name) ?? 'implementer', kind, model: inferModel(kind), dispatch: 'loop' };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,11 @@ 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',
|
||||||
@ -16,8 +18,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: options.assignedTo,
|
assignedTo,
|
||||||
claimedBy: options.claimedBy,
|
claimedBy: options.claimedBy ? resolveAgentName(cwd, options.claimedBy) : undefined,
|
||||||
reviewer: options.reviewer,
|
reviewer: options.reviewer,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@ -33,6 +35,50 @@ 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);
|
||||||
@ -72,6 +118,7 @@ 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) {
|
||||||
@ -82,6 +129,39 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,9 +170,16 @@ 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,
|
doneBy: meta?.doneBy ?? task.claimedBy,
|
||||||
doneTokens: meta?.doneTokens,
|
doneTokens: meta?.doneTokens,
|
||||||
doneDuration: meta?.doneDuration,
|
doneDuration: meta?.doneDuration,
|
||||||
});
|
});
|
||||||
@ -100,6 +187,12 @@ 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) {
|
||||||
@ -109,11 +202,33 @@ 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 {
|
||||||
return updateTask(cwd, id, { status: 'open' });
|
const { task } = getTask(cwd, id);
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -141,7 +256,12 @@ 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 {
|
||||||
return updateTask(cwd, id, { assignedTo: agentName });
|
const resolved = resolveAgentName(cwd, 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 {
|
||||||
@ -152,7 +272,7 @@ function getPreferredReviewer(cwd: string): string | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toIndexEntry(task: Task, filePath: string) {
|
export function toIndexEntry(task: Task, filePath: string) {
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
type: 'task',
|
type: 'task',
|
||||||
|
|||||||
@ -59,6 +59,45 @@ 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,10 +23,11 @@ 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 } = {}): void {
|
export function installMcp(cwd: string, opts: { print?: boolean; agent?: string } = {}): void {
|
||||||
const root = findProjectRoot(cwd) ?? cwd;
|
const root = findProjectRoot(cwd) ?? cwd;
|
||||||
const url = resolveUrl(root);
|
const url = resolveUrl(root);
|
||||||
const serverDef = { command: 'agenthub', args: ['mcp'], env: { AGENTHUB_SERVER: url } };
|
const args = ['mcp', ...(opts.agent ? ['--agent', opts.agent] : [])];
|
||||||
|
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();
|
||||||
@ -62,11 +63,11 @@ export function installMcp(cwd: string, opts: { print?: boolean } = {}): void {
|
|||||||
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 = ["mcp"]');
|
out(` args = ${JSON.stringify(args)}`);
|
||||||
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: ["mcp"] env: AGENTHUB_SERVER=${url}`);
|
out(` stdio server — command: agenthub args: ${JSON.stringify(args)} 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('');
|
||||||
|
|||||||
158
src/mcp/pushChannel.ts
Normal file
158
src/mcp/pushChannel.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
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,13 +18,32 @@ 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, markMessageRead } from '../core/services/messageService.js';
|
import { createMessage, listInbox } 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 } from '../discovery.js';
|
import { discoverServer as discoverHubServer, resolveReachableServerUrl } 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).
|
||||||
@ -38,7 +57,10 @@ import { discoverServer as discoverHubServer } from '../discovery.js';
|
|||||||
*
|
*
|
||||||
* Transport: stdio (each agent's CLI spawns `agenthub mcp` as a subprocess).
|
* Transport: stdio (each agent's CLI spawns `agenthub mcp` as a subprocess).
|
||||||
*/
|
*/
|
||||||
function resolveContext(cwd: string): { root: string; serverUrl?: string } {
|
export async function resolveMcpContext(
|
||||||
|
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) {
|
||||||
@ -48,7 +70,9 @@ function resolveContext(cwd: string): { root: string; serverUrl?: string } {
|
|||||||
// no project config — local/none
|
// no project config — local/none
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { root, serverUrl };
|
if (!serverUrl) return { root };
|
||||||
|
const reachable = await resolveReachableServerUrl(serverUrl, options);
|
||||||
|
return reachable ? { root, serverUrl: reachable } : { root };
|
||||||
}
|
}
|
||||||
|
|
||||||
function asText(value: unknown) {
|
function asText(value: unknown) {
|
||||||
@ -69,8 +93,14 @@ 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. */
|
/**
|
||||||
function waitForTask<T>(
|
* Block on the SSE stream until findClaim() returns a task, or timeout.
|
||||||
|
* 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,
|
||||||
@ -82,21 +112,47 @@ 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: { Accept: 'text/event-stream' },
|
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 may have arrived between the initial check and now.
|
// Close the gap: a task may have arrived between the initial check and now.
|
||||||
@ -113,6 +169,9 @@ 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')) {
|
||||||
@ -140,37 +199,60 @@ function waitForTask<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startMcpServer(cwd: string): Promise<void> {
|
export async function startMcpServer(cwd: string, options: { agent?: string } = {}): Promise<void> {
|
||||||
const { root, serverUrl } = resolveContext(cwd);
|
const { root, serverUrl } = await resolveMcpContext(cwd);
|
||||||
const remote = !!serverUrl;
|
const remote = !!serverUrl;
|
||||||
const server = new McpServer({ name: 'agenthub', version: '0.8.0' });
|
// `logging` MUSS deklariert werden: sendLoggingMessage prueft
|
||||||
|
// _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. 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. 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.',
|
||||||
{ 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 + mark-read the agent's unread messages, so the work loop surfaces
|
// Fetch the agent's unread messages so the work loop surfaces them.
|
||||||
// them once and doesn't spin on the same message.
|
// Surfacing flips them `unread` → `delivered` (the listInbox read
|
||||||
|
// 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;
|
||||||
const msgs = remote ? await remoteClient.getInbox(activeUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
|
return 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);
|
||||||
@ -194,6 +276,7 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
|||||||
// 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);
|
||||||
@ -208,6 +291,27 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -230,21 +334,76 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
|||||||
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) return asText({ ...immediate, loop: LOOP });
|
if (immediate) {
|
||||||
|
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);
|
||||||
const result = await waitForTask(serverUrl!, finder, timeoutSec ?? 300);
|
// Timeout-Aufloesung: explizites Argument > Roster-Einstellung des Agenten
|
||||||
if (result) return asText({ ...result, loop: LOOP });
|
// > globaler Default. Der Roster-Wert existiert, weil die Grenze am
|
||||||
|
// 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 ${timeoutSec ?? 300}s. ${LOOP}`);
|
return asText(`No ${kind} for ${agent} within ${effectiveTimeout}s. ${LOOP}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
server.tool('agenthub_task_list', 'List tasks, optionally filtered by status and/or role.',
|
server.tool('agenthub_task_list', 'List tasks newest-first, optionally filtered by status and/or role. Defaults to 50 and reports total/omitted counts.',
|
||||||
{ status: z.string().optional(), role: z.string().optional() },
|
{ status: z.string().optional(), role: z.string().optional(), limit: z.number().int().positive().max(200).optional() },
|
||||||
async ({ status, role }) => asText(remote ? await remoteClient.listTasks(serverUrl!, { status, role }) : listTasks(root, { status, role })));
|
async ({ status, role, limit }) => {
|
||||||
|
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() },
|
||||||
@ -268,7 +427,48 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
|||||||
|
|
||||||
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 }) => asText(remote ? await remoteClient.reviewTask(serverUrl!, id) : reviewTask(root, id)));
|
async ({ 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() },
|
||||||
@ -279,9 +479,30 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
|||||||
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 as you work (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 after EVERY step (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 }) => asText(remote ? await remoteClient.appendTaskLog(serverUrl!, id, { text, agent, level }) : appendTaskLog(root, id, { text, agent, level })));
|
async ({ 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() },
|
||||||
|
|||||||
362
src/server/agentHealth.ts
Normal file
362
src/server/agentHealth.ts
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
/**
|
||||||
|
* 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,6 +58,14 @@ 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>
|
||||||
@ -227,17 +235,15 @@ 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 lives inside the card while a task is worked (in_progress = live)
|
// Console toggle lives in the card; the console itself opens as a
|
||||||
// and stays available in review so you can see what the agent did.
|
// dropdown OVERLAY on <body> (see openConsole) — it spans the full
|
||||||
|
// 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) —
|
||||||
@ -461,13 +467,74 @@ 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]) return;
|
if (openConsoles[id]) loadConsole(id);
|
||||||
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) {
|
||||||
@ -563,6 +630,7 @@ 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() {
|
||||||
@ -624,6 +692,11 @@ 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) {
|
||||||
@ -633,6 +706,13 @@ 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;
|
||||||
@ -661,8 +741,14 @@ 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';
|
||||||
await patchTask(id, { status: 'in_progress', assignedTo: agent });
|
var rosterAgent = (AGENTS || []).find(function(a) { return a.name === agent; });
|
||||||
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : ''));
|
if (rosterAgent && rosterAgent.dispatch === 'architect') {
|
||||||
|
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 : ''));
|
||||||
@ -809,24 +895,67 @@ 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 body must not navigate to the task page.
|
// Clicks/selection inside the console overlay must not trigger card behavior.
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (e.target.closest && e.target.closest('.card-console')) e.preventDefault();
|
if (e.target.closest && e.target.closest('.console-overlay')) 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';
|
||||||
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
|
if (willOpen) openConsole(id);
|
||||||
if (panel) panel.hidden = !willOpen;
|
else closeConsole(id);
|
||||||
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() {
|
||||||
@ -845,6 +974,13 @@ 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');
|
||||||
@ -868,8 +1004,10 @@ 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 = await postTask({ title: title, description: description, priority: priority });
|
var task = externalInput.checked
|
||||||
toast(task.id + ' created');
|
? await recordTask({ title: title, description: description, priority: priority, doneBy: (externalBy.value || '').trim() })
|
||||||
|
: 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,6 +46,13 @@ ${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) {
|
||||||
@ -82,7 +89,9 @@ 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();
|
||||||
var time = c.minutes == null ? '' : (status === 'review' ? 'prüft ' : '') + c.minutes + 'm';
|
// No text prefix — keeps chips narrow enough for a single line (the
|
||||||
|
// 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,6 +22,10 @@ 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>
|
||||||
@ -61,5 +65,35 @@ 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: wrap; min-height: 24px; }
|
.b2-chips { display: flex; gap: 6px; margin-top: 10px; align-items: center; flex-wrap: nowrap; overflow: hidden; 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,9 +215,8 @@ 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); }
|
||||||
|
|
||||||
/* ── In-card live console (ported) ────────────────────────────────── */
|
/* ── Console dropdown overlay (full column width, floats above cards) ── */
|
||||||
.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;
|
||||||
@ -228,14 +227,27 @@ 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; } }
|
||||||
.card-console { margin-top: 7px; }
|
/* The panel itself lives on <body> (position:fixed, JS-anchored under its
|
||||||
.card-console[hidden] { display: none; }
|
card) so it escapes .card{overflow:hidden} and the narrow grid cell. */
|
||||||
.card-console-body { max-height: 180px; overflow-y: auto; background: rgba(0,0,0,.28);
|
.console-overlay { position: fixed; z-index: 70; background: var(--b2-raised);
|
||||||
|
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; }
|
||||||
@ -323,6 +335,17 @@ 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);
|
||||||
|
|||||||
73
src/server/eventLog.ts
Normal file
73
src/server/eventLog.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
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,9 +1,12 @@
|
|||||||
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. */
|
||||||
@ -12,6 +15,8 @@ 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;
|
||||||
}
|
}
|
||||||
@ -60,6 +65,11 @@ 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 {
|
||||||
@ -96,5 +106,13 @@ 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,6 +4,9 @@ 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.
|
||||||
@ -32,6 +35,17 @@ 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);
|
||||||
@ -104,13 +118,15 @@ 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);
|
||||||
timers.set(
|
const filePath = getEntityDir(cwd, dir) + '/' + name;
|
||||||
key,
|
const schedule = (attempt: number, delay: number) => {
|
||||||
setTimeout(() => {
|
timers.set(key, setTimeout(() => {
|
||||||
timers.delete(key);
|
timers.delete(key);
|
||||||
processFile(getEntityDir(cwd, dir) + '/' + name, type);
|
const error = processFile(cwd, filePath, type);
|
||||||
}, DEBOUNCE_MS),
|
if (error && attempt < MAX_READ_ATTEMPTS) schedule(attempt + 1, RETRY_MS);
|
||||||
);
|
}, delay));
|
||||||
|
};
|
||||||
|
schedule(1, DEBOUNCE_MS);
|
||||||
});
|
});
|
||||||
watchers.push(watcher);
|
watchers.push(watcher);
|
||||||
} catch {
|
} catch {
|
||||||
@ -126,13 +142,26 @@ export function startEntityWatcher(cwd: string): () => void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function processFile(filePath: string, type: AgentHubEventType): void {
|
function processFile(cwd: string, filePath: string, type: AgentHubEventType): Error | undefined {
|
||||||
let fm: Record<string, unknown>;
|
let fm: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
fm = readEntity(filePath).frontmatter;
|
const entity = readEntity(filePath);
|
||||||
} catch {
|
fm = entity.frontmatter;
|
||||||
// Deleted again, or read mid-write -> skip; a later settled write re-fires.
|
if (type === 'task') {
|
||||||
return;
|
const task = TaskSchema.parse(fm);
|
||||||
|
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,6 +4,7 @@ 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 });
|
||||||
@ -32,11 +33,14 @@ 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, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask, deleteTask } from '../core/services/taskService.js';
|
import { createTask, recordExternalTask, dispatchTask, 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,20 +11,27 @@ 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 { eventBus, emitChange } from './events.js';
|
import { configureDurableEvents, 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;
|
||||||
|
|
||||||
@ -46,6 +53,10 @@ 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.
|
||||||
@ -53,9 +64,130 @@ 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));
|
||||||
|
|
||||||
@ -121,6 +253,12 @@ 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();
|
||||||
@ -152,17 +290,26 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const listener = (event: AgentHubEvent) => {
|
const writeEvent = (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;
|
||||||
}
|
}
|
||||||
writeSse(`data: ${JSON.stringify(event)}\n\n`);
|
const idLine = event.seq !== undefined ? `id: ${event.seq}\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) => {
|
||||||
@ -188,6 +335,7 @@ 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',
|
||||||
@ -197,6 +345,24 @@ 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) }));
|
||||||
@ -263,6 +429,16 @@ 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 };
|
||||||
@ -314,8 +490,40 @@ 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 });
|
||||||
return entry;
|
// TSK-0274 Check-in-Kanal: ein arbeitender Agent ist zwischen zwei
|
||||||
|
// 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) => {
|
||||||
@ -325,7 +533,12 @@ 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) {
|
||||||
const assigned = assignTask(cwd, id, patch.assignedTo);
|
let assigned: Task;
|
||||||
|
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(
|
||||||
{
|
{
|
||||||
@ -345,7 +558,8 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
let task: Task;
|
let task: Task;
|
||||||
switch (patch.status) {
|
try {
|
||||||
|
switch (patch.status) {
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
{
|
{
|
||||||
const current = getTask(cwd, id).task;
|
const current = getTask(cwd, id).task;
|
||||||
@ -353,11 +567,8 @@ 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.
|
||||||
try {
|
task = claimTask(cwd, id, agent);
|
||||||
task = claimTask(cwd, id, agent);
|
stampSeen(agent);
|
||||||
} catch (err) {
|
|
||||||
return badRequest(reply, err instanceof Error ? err.message : 'Cannot claim task');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'done':
|
case 'done':
|
||||||
@ -366,9 +577,12 @@ 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);
|
||||||
@ -376,8 +590,13 @@ 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 /
|
||||||
@ -487,6 +706,7 @@ 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',
|
||||||
@ -494,6 +714,7 @@ 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);min-width:0; }
|
.log-text { color:var(--text);flex:1 1 auto;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';
|
export type HeaderPage = 'board' | 'team' | 'activity' | 'messages' | 'decisions' | 'archive' | 'task' | 'health';
|
||||||
|
|
||||||
/** 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,6 +408,7 @@ 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')}
|
||||||
|
|||||||
226
src/server/watchdog.ts
Normal file
226
src/server/watchdog.ts
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
2
src/version.ts
Normal file
2
src/version.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/** 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, doneTask } from '../src/core/services/taskService.js';
|
import { createTask, claimTask, reviewTask, 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,6 +41,8 @@ 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');
|
||||||
|
|||||||
15
tests/agent-setup.test.ts
Normal file
15
tests/agent-setup.test.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
99
tests/agentHealth.test.ts
Normal file
99
tests/agentHealth.test.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
59
tests/architectBudget.test.ts
Normal file
59
tests/architectBudget.test.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
230
tests/autowake.test.ts
Normal file
230
tests/autowake.test.ts
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* 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,4 +27,7 @@ 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,4 +13,9 @@ 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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
211
tests/checkinService.test.ts
Normal file
211
tests/checkinService.test.ts
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
111
tests/health.test.ts
Normal file
111
tests/health.test.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
tests/identityService.test.ts
Normal file
37
tests/identityService.test.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
91
tests/mcp.test.ts
Normal file
91
tests/mcp.test.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
77
tests/push-channel.test.ts
Normal file
77
tests/push-channel.test.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
210
tests/realtime-regression.test.ts
Normal file
210
tests/realtime-regression.test.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
57
tests/role-switch.test.ts
Normal file
57
tests/role-switch.test.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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,6 +4,7 @@ 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;
|
||||||
@ -37,6 +38,57 @@ 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);
|
||||||
@ -64,11 +116,33 @@ 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' } });
|
||||||
@ -83,6 +157,7 @@ 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);
|
||||||
@ -103,8 +178,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' } });
|
||||||
// First cancel it, then reopen
|
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: 'cancelled' } });
|
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: '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');
|
||||||
@ -113,6 +188,7 @@ 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' } });
|
||||||
|
|
||||||
@ -203,6 +279,8 @@ 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',
|
||||||
@ -251,6 +329,16 @@ 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',
|
||||||
@ -269,6 +357,8 @@ 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',
|
||||||
|
|||||||
203
tests/singleClaim.test.ts
Normal file
203
tests/singleClaim.test.ts
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* 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 } from 'fs';
|
import { mkdtempSync, rmSync, readFileSync, writeFileSync } 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,8 +17,9 @@ 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 { startEntityWatcher } from '../src/server/fsWatch.js';
|
import { getFsWatchErrors, resetFsWatchErrors, startEntityWatcher } from '../src/server/fsWatch.js';
|
||||||
import { createTask, claimTask } from '../src/core/services/taskService.js';
|
import { createTask, claimTask, listTasks } from '../src/core/services/taskService.js';
|
||||||
|
import { listHubEventsAfter } from '../src/server/eventLog.js';
|
||||||
|
|
||||||
// ─── 1. Unit: SSE buffer parser ──────────────────────────────────────────────
|
// ─── 1. Unit: SSE buffer parser ──────────────────────────────────────────────
|
||||||
|
|
||||||
@ -74,6 +75,19 @@ 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 ────────────────────────────────────────────────────
|
||||||
@ -168,15 +182,18 @@ 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' } });
|
||||||
collected.length = 0; // clear the created event
|
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' } });
|
||||||
|
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 () => {
|
||||||
@ -190,6 +207,7 @@ 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);
|
||||||
@ -198,6 +216,7 @@ 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' } });
|
||||||
@ -441,6 +460,67 @@ 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
|
||||||
@ -483,6 +563,7 @@ 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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -509,6 +590,37 @@ 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,10 +4,11 @@ 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,
|
claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask, recordExternalTask, dispatchTask,
|
||||||
} 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;
|
||||||
@ -39,12 +40,15 @@ 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
|
||||||
@ -55,6 +59,7 @@ 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');
|
||||||
@ -68,6 +73,7 @@ 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');
|
||||||
});
|
});
|
||||||
@ -81,10 +87,13 @@ describe('taskService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('reopens a task (any status → open)', () => {
|
it('reopens a task (any status → open)', () => {
|
||||||
const task = createTask(cwd, { title: 'E', role: 'implementer' });
|
const task = createTask(cwd, { title: 'E', role: 'implementer', assignedTo: 'kimi' });
|
||||||
cancelTask(cwd, task.id);
|
claimTask(cwd, task.id, 'kimi');
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
@ -122,4 +131,23 @@ 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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
198
tests/watchdog.test.ts
Normal file
198
tests/watchdog.test.ts
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
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,12 +131,14 @@ 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 + marked the message read — proving it woke on the message,
|
// The loop drained + surfaced the message (unread → delivered) — proving it
|
||||||
// not that it merely timed out (a timeout would leave it delivered/unread).
|
// woke on the message, not that it merely timed out (a timeout would leave
|
||||||
|
// 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('read');
|
expect(m?.status).toBe('delivered');
|
||||||
}, 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