From 910c07390f5ab6c9d909aaa913e93261732b2a1b Mon Sep 17 00:00:00 2001 From: reoring Date: Mon, 9 Mar 2026 08:48:57 +0900 Subject: [PATCH 1/6] feat(agents): add workflow status and richer telemetry --- CHANGELOG.md | 19 + docs/features/agents.md | 8 +- docs/guides/agents.md | 11 + docs/spec/agents.md | 2 +- package.json | 2 +- src/agents/cli.ts | 132 ++++++- src/agents/coordinator.ts | 126 ++++++- src/agents/paths.ts | 5 + src/agents/resident-agent.ts | 64 ++-- src/agents/status-store.ts | 72 ++++ src/agents/task-store.ts | 173 ++++++++- src/agents/types.ts | 11 +- src/core/agentcore.ts | 1 + src/tui/components/detail-pane.ts | 39 +- src/tui/components/header.ts | 7 +- src/tui/components/step-list.ts | 63 +++- src/tui/components/tabs/agent-overview-tab.ts | 145 ++++++- src/tui/components/tabs/agents-tab.ts | 259 ++++++++++++- src/tui/components/tabs/help-tab.ts | 30 +- src/tui/components/tabs/logs-tab.ts | 357 +++++++++++++++++- src/tui/components/tabs/overview-tab.ts | 51 +++ src/tui/components/tabs/raw-logs-tab.ts | 56 +++ src/tui/components/tabs/result-tab.ts | 6 + src/tui/components/tabs/usage-tab.ts | 94 +++++ src/tui/exec-event.ts | 8 + src/tui/state-store.ts | 209 +++++++++- src/tui/workflow-tui.ts | 52 ++- src/types/worker-result.ts | 1 + src/types/worker-task-job-payload.ts | 8 + src/types/worker-task.ts | 2 + src/worker/adapters/claude-code-adapter.ts | 2 +- src/worker/adapters/codex-cli-adapter.ts | 57 ++- src/worker/adapters/opencode-adapter.ts | 3 +- src/workflow/agent-catalog.ts | 16 + src/workflow/core-ipc-step-runner.ts | 1 + src/workflow/executor.ts | 26 +- src/workflow/management/types.ts | 2 + src/workflow/management/worker-engine.ts | 1 + src/workflow/parser.ts | 60 ++- src/workflow/resolve-worker-task.ts | 4 + src/workflow/run.ts | 8 +- src/workflow/supervised-ipc-capability.ts | 71 +++- src/workflow/types.ts | 9 + test/integration/core-ipc-step-runner.test.ts | 44 +++ test/integration/management-executor.test.ts | 6 + test/unit/agents/resident-agent.test.ts | 64 ++++ test/unit/agents/task-claiming.test.ts | 152 ++++++++ test/unit/agents/task-store.test.ts | 85 +++++ test/unit/tui/state-store.test.ts | 195 ++++++++++ test/unit/tui/step-list.test.ts | 37 ++ .../types/worker-task-job-payload.test.ts | 5 + test/unit/worker/claude-code-adapter.test.ts | 15 + test/unit/worker/codex-cli-adapter.test.ts | 36 ++ test/unit/worker/opencode-adapter.test.ts | 15 + test/unit/workflow/agent-catalog.test.ts | 21 ++ test/unit/workflow/executor.test.ts | 102 +++++ test/unit/workflow/parser.test.ts | 121 +++++- .../unit/workflow/resolve-worker-task.test.ts | 22 ++ .../supervised-ipc-capability.test.ts | 70 ++++ tests/at/agents-cli.test.ts | 193 +++++++++- tests/unit/agents/agents-tab.test.ts | 131 ++++++- tests/unit/agents/coordinator.test.ts | 189 +++++++++- tests/unit/agents/status-store.test.ts | 56 +++ tests/unit/agents/task-store.test.ts | 49 +++ tests/unit/tui/agent-overview-tab.test.ts | 118 +++++- tests/unit/tui/detail-pane.test.ts | 7 + tests/unit/tui/logs-tab.test.ts | 156 ++++++++ tests/unit/tui/overview-tab.test.ts | 83 ++++ tests/unit/tui/step-list.test.ts | 3 + 69 files changed, 4071 insertions(+), 177 deletions(-) create mode 100644 src/agents/status-store.ts create mode 100644 src/tui/components/tabs/raw-logs-tab.ts create mode 100644 src/tui/components/tabs/usage-tab.ts create mode 100644 test/unit/agents/resident-agent.test.ts create mode 100644 test/unit/agents/task-claiming.test.ts create mode 100644 test/unit/agents/task-store.test.ts create mode 100644 test/unit/tui/step-list.test.ts create mode 100644 test/unit/workflow/supervised-ipc-capability.test.ts create mode 100644 tests/unit/agents/status-store.test.ts create mode 100644 tests/unit/tui/logs-tab.test.ts create mode 100644 tests/unit/tui/overview-tab.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e0508fe..8d73768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,25 @@ The format is based on Keep a Changelog, and this project follows SemVer where p ### Fixed +## [0.1.10] - 2026-03-09 + +### Added + +- Added `roboppi agents tasks supersede` and `roboppi agents status get|set` for file-backed workflow status summaries and task replacement flow. +- Extended the agents workflow DSL with seed task `id`/`depends_on`/`tags`/`requires_plan_approval`, member `role`, and worker `defaultArgs`. +- Added TUI Timeline/Raw/Usage tabs with structured LLM log summaries, agent runtime usage stats, and dormant-agent visibility. + +### Changed + +- Agent coordination now prunes stopped resident agents before reconcile, skips dormant members, restores mailbox body previews, and emits task-superseded activity events. +- The Codex CLI adapter now normalizes legacy sandbox/approval flags and merges task/profile `defaultArgs` before dispatch. + +### Fixed + +- Clarified agent task claimability and dependency handling, including seeded dependency resolution and broader test coverage. +- Added verification around supervised IPC capability detection, TUI state/runtime accounting, and prompt-byte tracking in worker costs. + + ## [0.1.9] - 2026-03-06 ### Added diff --git a/docs/features/agents.md b/docs/features/agents.md index 656ebc4..72cf6a9 100644 --- a/docs/features/agents.md +++ b/docs/features/agents.md @@ -146,6 +146,7 @@ Directory root: in_progress/ completed/ blocked/ + superseded/ tmp/ _events.jsonl locks/ @@ -286,8 +287,9 @@ Task transitions are represented by moving the file: - `pending/.json` -> `in_progress/.json` - `in_progress/.json` -> `completed/.json` +- `pending|in_progress|blocked/.json` -> `superseded/.json` -Blocked tasks can be stored in `blocked/` to make discovery fast. +Blocked tasks can be stored in `blocked/` to make discovery fast, and stale coordination tasks can be moved to `superseded/` to preserve audit history without leaving them claimable. ### 4.4 Event logs @@ -308,6 +310,7 @@ audit. These logs MUST be safe by default. { "ts": 1710000000000, "type": "task_added", "task_id": "...", "title": "..." } { "ts": 1710000005000, "type": "task_claimed", "task_id": "...", "by": "researcher" } { "ts": 1710000010000, "type": "task_completed", "task_id": "...", "by": "researcher" } +{ "ts": 1710000015000, "type": "task_superseded", "task_id": "...", "by": "lead", "reason": "stale contract" } ``` The full message body SHOULD NOT be copied into `_events.jsonl` unless explicitly @@ -408,9 +411,10 @@ Add a `roboppi agents` CLI group: - `roboppi agents message ack --context --for --message-id ` - `roboppi agents tasks add --context --title ... --description ... [--depends-on ...]` -- `roboppi agents tasks list --context [--status pending|in_progress|completed]` +- `roboppi agents tasks list --context [--status pending|in_progress|completed|blocked|superseded]` - `roboppi agents tasks claim --context --task-id --member ` - `roboppi agents tasks complete --context --task-id --member [--artifact ]` +- `roboppi agents tasks supersede --context --task-id --member [--reason ] [--replacement-task-id ]` Return values MUST be JSON so workers can parse them reliably. diff --git a/docs/guides/agents.md b/docs/guides/agents.md index 27373bb..216f574 100644 --- a/docs/guides/agents.md +++ b/docs/guides/agents.md @@ -248,6 +248,17 @@ roboppi agents tasks complete \ Artifact paths must be relative and must not contain `..` segments. +Supersede stale coordination work without pretending it succeeded: + +```bash +roboppi agents tasks supersede \ + --context "$ROBOPPI_AGENTS_CONTEXT_DIR" \ + --task-id "" \ + --member lead \ + --reason "stale rerun contract" \ + --replacement-task-id "" +``` + ### Housekeeping Requeue stale processing messages and stale in-progress tasks: diff --git a/docs/spec/agents.md b/docs/spec/agents.md index 8a479b2..7ad3afc 100644 --- a/docs/spec/agents.md +++ b/docs/spec/agents.md @@ -44,7 +44,7 @@ The following are treated as implemented baseline: - `init` - `members list` - `message send|broadcast|recv|ack` - - `tasks add|list|claim|complete` + - `tasks add|list|claim|complete|supersede` - `housekeep` - Metadata-only `_events.jsonl` emission for mailbox/tasks. - Capability wiring of `MAILBOX` / `TASKS` in workflow/daemon/type layers. diff --git a/package.json b/package.json index 57330c6..bacda8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roboppi", - "version": "0.1.9", + "version": "0.1.10", "type": "module", "bin": { "roboppi": "src/cli.ts" diff --git a/src/agents/cli.ts b/src/agents/cli.ts index 9e9cc73..369da4d 100644 --- a/src/agents/cli.ts +++ b/src/agents/cli.ts @@ -24,9 +24,11 @@ import { listTasks, claimTask, completeTask, + supersedeTask, } from "./task-store.js"; import { housekeepMailbox, housekeepTasksInProgress } from "./housekeeping.js"; import { runChat } from "./chat.js"; +import { readWorkflowStatus, writeWorkflowStatus, clearWorkflowStatus } from "./status-store.js"; import { DEFAULT_RECV_POLL_INTERVAL_MS, DEFAULT_CHAT_POLL_INTERVAL_MS } from "./constants.js"; import { assertAgentsRootSafe, validateMemberIdPath, validateIdPath } from "./path-safety.js"; import type { MemberEntry, MessageKind, TaskStatus } from "./types.js"; @@ -101,6 +103,16 @@ async function requireLeadIdentity(contextDir: string): Promise { } } +function requireCallerMatchesRequestedMember(memberId: string): void { + const callerMemberId = process.env.ROBOPPI_AGENTS_MEMBER_ID; + if (!callerMemberId) { + return; + } + if (callerMemberId !== memberId) { + die(`Requested member "${memberId}" does not match caller "${callerMemberId}"`); + } +} + // --------------------------------------------------------------------------- // Subcommand handlers // --------------------------------------------------------------------------- @@ -640,6 +652,7 @@ async function handleTasksClaim(argv: string[]): Promise { if (!taskId) die("--task-id is required"); if (!memberId) die("--member is required (or set ROBOPPI_AGENTS_MEMBER_ID)"); + requireCallerMatchesRequestedMember(memberId); // Spec 3.5: path safety await assertAgentsRootSafe(contextDir); @@ -666,6 +679,7 @@ async function handleTasksComplete(argv: string[]): Promise { if (!taskId) die("--task-id is required"); if (!memberId) die("--member is required (or set ROBOPPI_AGENTS_MEMBER_ID)"); + requireCallerMatchesRequestedMember(memberId); // Spec 3.5: path safety await assertAgentsRootSafe(contextDir); @@ -683,6 +697,43 @@ async function handleTasksComplete(argv: string[]): Promise { if (!result.ok) process.exit(1); } + + +async function handleTasksSupersede(argv: string[]): Promise { + const contextDir = resolveContextDir(argv); + let taskId = ""; + let memberId = resolveEnvDefault(argv, "--member", "ROBOPPI_AGENTS_MEMBER_ID") ?? ""; + let reason: string | undefined; + let replacementTaskId: string | undefined; + + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--task-id") taskId = requireArg(argv, i, "--task-id"); + if (argv[i] === "--member") memberId = requireArg(argv, i, "--member"); + if (argv[i] === "--reason") reason = requireArg(argv, i, "--reason"); + if (argv[i] === "--replacement-task-id") replacementTaskId = requireArg(argv, i, "--replacement-task-id"); + } + + if (!taskId) die("--task-id is required"); + if (!memberId) die("--member is required (or set ROBOPPI_AGENTS_MEMBER_ID)"); + requireCallerMatchesRequestedMember(memberId); + + await assertAgentsRootSafe(contextDir); + validateMemberIdPath(memberId); + validateIdPath(taskId, "task-id"); + if (replacementTaskId) validateIdPath(replacementTaskId, "replacement-task-id"); + await validateMember(contextDir, memberId); + + const result = await supersedeTask( + contextDir, + taskId, + memberId, + reason, + replacementTaskId, + ); + jsonOut(result); + if (!result.ok) process.exit(1); +} + async function handleHousekeep(argv: string[]): Promise { const contextDir = resolveContextDir(argv); @@ -723,6 +774,64 @@ async function handleChat(argv: string[]): Promise { await runChat({ contextDir, memberId, pollMs }); } +async function handleStatusGet(argv: string[]): Promise { + const contextDir = resolveContextDir(argv); + const status = await readWorkflowStatus(contextDir); + jsonOut({ ok: true, status }); +} + +async function handleStatusSet(argv: string[]): Promise { + const contextDir = resolveContextDir(argv); + let summary = ""; + let ownerMemberId = resolveEnvDefault(argv, "--owner", "ROBOPPI_AGENTS_MEMBER_ID") ?? ""; + const blockers: string[] = []; + const nextActions: string[] = []; + let useJsonStdin = false; + let clear = false; + + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--summary") summary = requireArg(argv, i, "--summary"); + if (argv[i] === "--owner") ownerMemberId = requireArg(argv, i, "--owner"); + if (argv[i] === "--blocker") blockers.push(requireArg(argv, i, "--blocker")); + if (argv[i] === "--next") nextActions.push(requireArg(argv, i, "--next")); + if (argv[i] === "--json-stdin") useJsonStdin = true; + if (argv[i] === "--clear") clear = true; + } + + if (useJsonStdin) { + const input = await readJsonStdin() as Record; + if (typeof input.summary === "string") summary = input.summary; + if (typeof input.owner_member_id === "string") ownerMemberId = input.owner_member_id; + if (Array.isArray(input.blockers)) { + blockers.splice(0, blockers.length, ...input.blockers.filter((v): v is string => typeof v === "string")); + } + if (Array.isArray(input.next_actions)) { + nextActions.splice(0, nextActions.length, ...input.next_actions.filter((v): v is string => typeof v === "string")); + } + if (input.clear === true) clear = true; + } + + if (clear) { + await clearWorkflowStatus(contextDir); + jsonOut({ ok: true, cleared: true }); + return; + } + + if (!ownerMemberId) { + die("--owner is required (or set ROBOPPI_AGENTS_MEMBER_ID)"); + } + await validateMember(contextDir, ownerMemberId); + + const status = await writeWorkflowStatus({ + contextDir, + ownerMemberId, + summary, + blockers, + nextActions, + }); + jsonOut({ ok: true, status }); +} + // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- @@ -742,9 +851,12 @@ USAGE roboppi agents message ack --context --for --message-id |--claim-token roboppi agents message reply --context --for --claim-token --body [--topic ] [--kind ] [--json-stdin] roboppi agents tasks add --context --title --description [--json-stdin] - roboppi agents tasks list --context [--status pending|in_progress|completed|blocked] + roboppi agents tasks list --context [--status pending|in_progress|completed|blocked|superseded] roboppi agents tasks claim --context --task-id --member roboppi agents tasks complete --context --task-id --member [--artifact

] + roboppi agents tasks supersede --context

--task-id --member [--reason ] [--replacement-task-id ] + roboppi agents status get --context + roboppi agents status set --context --summary [--owner ] [--blocker ]... [--next ]... [--json-stdin|--clear] roboppi agents chat --context [--as ] [--poll-ms ] roboppi agents housekeep --context @@ -826,8 +938,24 @@ export async function runAgentsCli(argv: string[]): Promise { case "complete": await handleTasksComplete(rest.slice(1)); break; + case "supersede": + await handleTasksSupersede(rest.slice(1)); + break; + default: + die(`Unknown tasks subcommand: ${rest[0]}. Use: add|list|claim|complete|supersede`); + } + break; + + case "status": + switch (rest[0]) { + case "get": + await handleStatusGet(rest.slice(1)); + break; + case "set": + await handleStatusSet(rest.slice(1)); + break; default: - die(`Unknown tasks subcommand: ${rest[0]}. Use: add|list|claim|complete`); + die(`Unknown status subcommand: ${rest[0]}. Use: get|set`); } break; diff --git a/src/agents/coordinator.ts b/src/agents/coordinator.ts index 1d84aab..e5c517f 100644 --- a/src/agents/coordinator.ts +++ b/src/agents/coordinator.ts @@ -10,21 +10,33 @@ * * See `docs/features/agents.md` §3, §7. */ -import { readFile, rm, rename as fsRename, mkdir } from "node:fs/promises"; +import { readFile, readdir, rm, rename as fsRename, mkdir } from "node:fs/promises"; import { resolve } from "node:path"; import { randomUUID } from "node:crypto"; import { housekeepMailbox, housekeepTasksInProgress } from "./housekeeping.js"; import { readTeam, readMembers, deliverMessage } from "./store.js"; import { appendAgentEvent } from "./agent-events.js"; -import { mailboxEventsPath, tasksEventsPath, membersJsonPath, mailboxRoot, tasksRoot, agentsRoot } from "./paths.js"; +import { + mailboxEventsPath, + tasksEventsPath, + membersJsonPath, + mailboxRoot, + tasksRoot, + agentsRoot, + inboxNew, + inboxProcessing, + inboxCur, + inboxDead, +} from "./paths.js"; import { ResidentAgent } from "./resident-agent.js"; import { DEFAULT_RECONCILE_INTERVAL_MS, DEFAULT_MAX_TEAMMATES, DEFAULT_MAX_SPAWNS_PER_MINUTE, + DEFAULT_BROKER_PREVIEW_MAX_BYTES, } from "./constants.js"; import type { ExecEventSink } from "../tui/exec-event.js"; -import type { MailboxEvent, TaskEvent, CleanupPolicy, MemberEntry } from "./types.js"; +import type { AgentMessage, MailboxEvent, TaskEvent, CleanupPolicy, MemberEntry } from "./types.js"; import type { StepRunner, StepRunResult } from "../workflow/executor.js"; import type { AgentMemberConfig } from "../workflow/types.js"; import type { AgentCatalog, AgentProfile } from "../workflow/agent-catalog.js"; @@ -33,6 +45,18 @@ import type { StepDefinition } from "../workflow/types.js"; const DEFAULT_HOUSEKEEP_INTERVAL_MS = 60_000; // 1 minute const DEFAULT_TAIL_INTERVAL_MS = 2_000; // 2 seconds const DEFAULT_TEAMMATE_SHUTDOWN_WAIT_MS = 30_000; // 30 seconds +const CHAT_BODY_PREVIEW_MAX_BYTES = DEFAULT_BROKER_PREVIEW_MAX_BYTES; + +function truncateBodyPreview(body: string): string | undefined { + if (!body) return undefined; + + const bodyBytes = Buffer.from(body, "utf-8"); + if (bodyBytes.length <= CHAT_BODY_PREVIEW_MAX_BYTES) { + return body; + } + + return `${bodyBytes.subarray(0, CHAT_BODY_PREVIEW_MAX_BYTES).toString("utf-8")}...`; +} export interface AgentCoordinatorOptions { contextDir: string; @@ -350,13 +374,22 @@ export class AgentCoordinator { return; // members.json not readable yet } + if (this.stopped) return; + // Sort by member_id for determinism desiredMembers.sort((a, b) => a.member_id.localeCompare(b.member_id)); + // A desired member may still need to respawn if its prior ResidentAgent + // stopped unexpectedly. Prune stopped entries before computing spawn gaps + // so reconcile does not treat a dead agent as satisfying desired state. + this.removeStoppedResidentAgents(); + + if (this.stopped) return; + // Build sets for diff const desiredIds = new Set( desiredMembers - .filter((m) => m.member_id !== this.leadMemberId && m.role !== "human") + .filter((m) => m.member_id !== this.leadMemberId && m.role !== "human" && m.role !== "dormant") .map((m) => m.member_id), ); const runningResidentIds = new Set( @@ -372,8 +405,10 @@ export class AgentCoordinator { // Spawn missing (desired but not running) for (const member of desiredMembers) { + if (this.stopped) return; if (member.member_id === this.leadMemberId) continue; if (member.role === "human") continue; + if (member.role === "dormant") continue; if (runningResidentIds.has(member.member_id)) continue; if (runningLegacyIds.has(member.member_id)) continue; // Skip members with existing resident agents (even stopped — cleaned below) @@ -403,8 +438,12 @@ export class AgentCoordinator { } await this.spawnSingleTeammate(member); + + if (this.stopped) return; } + if (this.stopped) return; + // Shutdown removed resident agents for (const agent of this.residentAgents) { if (desiredIds.has(agent.memberId)) continue; @@ -435,6 +474,15 @@ export class AgentCoordinator { } } + private removeStoppedResidentAgents(): void { + for (let i = this.residentAgents.length - 1; i >= 0; i--) { + const agent = this.residentAgents[i]!; + if (!agent.isRunning) { + this.residentAgents.splice(i, 1); + } + } + } + private checkSpawnRateLimit(): boolean { const now = Date.now(); const oneMinuteAgo = now - 60_000; @@ -672,7 +720,7 @@ export class AgentCoordinator { for (const line of newLines) { try { const event = JSON.parse(line) as MailboxEvent; - this.emitMailboxEvent(event); + await this.emitMailboxEvent(event); } catch { // skip malformed lines } @@ -702,8 +750,9 @@ export class AgentCoordinator { } } - private emitMailboxEvent(event: MailboxEvent): void { + private async emitMailboxEvent(event: MailboxEvent): Promise { const teamId = this.teamId ?? "unknown"; + const bodyPreview = await this.resolveMailboxBodyPreview(event); switch (event.type) { case "message_delivered": @@ -715,7 +764,7 @@ export class AgentCoordinator { fromMemberId: event.from ?? "unknown", toMemberId: event.to, topic: event.topic ?? "", - bodyPreview: event.body_preview, + bodyPreview, }); break; @@ -728,7 +777,7 @@ export class AgentCoordinator { fromMemberId: event.from ?? "unknown", toMemberId: event.by ?? event.to ?? "unknown", topic: event.topic ?? "", - bodyPreview: event.body_preview, + bodyPreview, }); break; @@ -744,6 +793,56 @@ export class AgentCoordinator { } } + private async resolveMailboxBodyPreview( + event: MailboxEvent, + ): Promise { + if (event.body_preview) return event.body_preview; + if (event.type !== "message_delivered" && event.type !== "message_claimed") { + return undefined; + } + + const recipientId = event.to ?? event.by; + if (!recipientId) return undefined; + + const message = await this.readMailboxMessage(recipientId, event.message_id); + if (!message?.body) return undefined; + + return truncateBodyPreview(message.body); + } + + private async readMailboxMessage( + memberId: string, + messageId: string, + ): Promise { + const dirs = [ + inboxNew(this.contextDir, memberId), + inboxProcessing(this.contextDir, memberId), + inboxCur(this.contextDir, memberId), + inboxDead(this.contextDir, memberId), + ]; + + for (const dir of dirs) { + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + continue; + } + + const filename = entries.find((entry) => entry.endsWith(".json") && entry.includes(messageId)); + if (!filename) continue; + + try { + const raw = await readFile(resolve(dir, filename), "utf-8"); + return JSON.parse(raw) as AgentMessage; + } catch { + continue; + } + } + + return null; + } + private emitTaskEvent(event: TaskEvent): void { const teamId = this.teamId ?? "unknown"; @@ -770,6 +869,17 @@ export class AgentCoordinator { }); break; + case "task_superseded": + this.sink.emit({ + type: "agent_task_superseded", + ts: event.ts, + teamId, + taskId: event.task_id, + byMemberId: event.by ?? "unknown", + title: event.title, + }); + break; + // task_added, task_blocked: no specific ExecEvent type, emit as info if needed } } diff --git a/src/agents/paths.ts b/src/agents/paths.ts index 9887d4d..d290c14 100644 --- a/src/agents/paths.ts +++ b/src/agents/paths.ts @@ -109,6 +109,10 @@ export function inboxSummaryPath(contextDir: string): string { return resolve(agentsRoot(contextDir), "inbox-summary.json"); } +export function workflowStatusPath(contextDir: string): string { + return resolve(agentsRoot(contextDir), "workflow-status.json"); +} + // -- Agent-level events (survive mailbox/tasks cleanup) ------------------- export function agentEventsPath(contextDir: string): string { @@ -136,6 +140,7 @@ export function allDirs(contextDir: string, memberIds: string[]): string[] { tasksStatusDir(contextDir, "in_progress"), tasksStatusDir(contextDir, "completed"), tasksStatusDir(contextDir, "blocked"), + tasksStatusDir(contextDir, "superseded"), locksDir(contextDir), ]; for (const id of memberIds) { diff --git a/src/agents/resident-agent.ts b/src/agents/resident-agent.ts index 05b15b8..89c6d9a 100644 --- a/src/agents/resident-agent.ts +++ b/src/agents/resident-agent.ts @@ -16,7 +16,7 @@ import { mkdir } from "node:fs/promises"; import { recvMessages } from "./store.js"; -import { listTasks } from "./task-store.js"; +import { hasActionableTaskForMember } from "./task-store.js"; import { allDirs } from "./paths.js"; import { DEFAULT_RESIDENT_POLL_INTERVAL_MS, @@ -63,6 +63,30 @@ interface SessionEntry { summary: string; } +function cloneProfileDefaultArgs(profile: AgentProfile): string[] { + return Array.isArray(profile.defaultArgs) ? [...profile.defaultArgs] : []; +} + +export function createAdapterForAgentProfile( + processManager: ProcessManager, + profile: AgentProfile, +): WorkerAdapter { + const defaultArgs = cloneProfileDefaultArgs(profile); + + switch (profile.worker) { + case "OPENCODE": + return new OpenCodeAdapter(processManager, { defaultArgs }); + case "CODEX_CLI": + return new CodexCliAdapter(processManager, { defaultArgs }); + case "CLAUDE_CODE": + default: + return new ClaudeCodeAdapter( + { outputFormat: "json", defaultArgs }, + processManager, + ); + } +} + // --------------------------------------------------------------------------- // ResidentAgent // --------------------------------------------------------------------------- @@ -113,7 +137,7 @@ export class ResidentAgent { // Create adapter based on worker kind this.processManager = new ProcessManager(); - this.adapter = this.createAdapter(opts.profile.worker ?? "CLAUDE_CODE"); + this.adapter = createAdapterForAgentProfile(this.processManager, opts.profile); this.settled = new Promise((resolve) => { this.resolveSettled = resolve; @@ -249,12 +273,7 @@ export class ResidentAgent { private async hasClaimableTasks(): Promise { try { - const tasks = await listTasks(this.contextDir, "pending"); - return tasks.some( - (t) => - t.assigned_to === this.memberId || - (!t.assigned_to && !t.claimed_by), - ); + return await hasActionableTaskForMember(this.contextDir, this.memberId); } catch { return false; } @@ -270,6 +289,7 @@ export class ResidentAgent { ): Promise { this.dispatching = true; this.dispatchAbortController = new AbortController(); + const instructions = this.buildInstructions(pendingMessages, hasTasks); // Emit phase: executing this.sink.emit({ @@ -277,10 +297,12 @@ export class ResidentAgent { stepId: this.stepId, phase: "executing", at: Date.now(), + detail: { + instructions, + }, }); try { - const instructions = this.buildInstructions(pendingMessages, hasTasks); const result = await this.runCliAgent(instructions); // Record dispatch result in session history @@ -320,6 +342,7 @@ export class ResidentAgent { } private async runCliAgent(instructions: string): Promise { + const instructionBytes = new TextEncoder().encode(instructions).length; const task: WorkerTask = { workerTaskId: generateId(), workerKind: this.resolveWorkerKind(), @@ -360,6 +383,11 @@ export class ResidentAgent { const result = await this.adapter.awaitResult(handle); await streamDone; + result.cost = { + ...result.cost, + instructionBytes, + }; + // Emit final result this.sink.emit({ type: "worker_result", @@ -447,6 +475,9 @@ export class ResidentAgent { parts.push( " roboppi agents tasks complete --task-id # when done", ); + parts.push( + " roboppi agents tasks supersede --task-id --member --reason [--replacement-task-id ] # when your task/input is stale", + ); parts.push(""); // Session context from previous dispatches @@ -478,21 +509,6 @@ export class ResidentAgent { // Helpers // ----------------------------------------------------------------------- - private createAdapter(workerKind: string): WorkerAdapter { - switch (workerKind) { - case "OPENCODE": - return new OpenCodeAdapter(this.processManager); - case "CODEX_CLI": - return new CodexCliAdapter(this.processManager); - case "CLAUDE_CODE": - default: - return new ClaudeCodeAdapter( - { outputFormat: "json" }, - this.processManager, - ); - } - } - private resolveWorkerKind(): WorkerKind { switch (this.profile.worker) { case "OPENCODE": diff --git a/src/agents/status-store.ts b/src/agents/status-store.ts new file mode 100644 index 0000000..b657e5a --- /dev/null +++ b/src/agents/status-store.ts @@ -0,0 +1,72 @@ +import { mkdir, readFile, rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +import { atomicJsonWrite } from "./fs-atomic.js"; +import { agentsRoot, workflowStatusPath } from "./paths.js"; + +export interface WorkflowStatusSummary { + version: "1"; + updated_at: number; + owner_member_id: string; + summary: string; + blockers: string[]; + next_actions: string[]; +} + +export interface WriteWorkflowStatusOptions { + contextDir: string; + ownerMemberId: string; + summary: string; + blockers?: string[]; + nextActions?: string[]; +} + +function assertWorkflowStatusSummary(data: unknown): asserts data is WorkflowStatusSummary { + const d = data as Record; + if (d.version !== "1") throw new Error(`Unsupported workflow-status.json version: ${d.version}`); + if (typeof d.updated_at !== "number") throw new Error("workflow-status.json: missing updated_at"); + if (typeof d.owner_member_id !== "string") throw new Error("workflow-status.json: missing owner_member_id"); + if (typeof d.summary !== "string") throw new Error("workflow-status.json: missing summary"); + if (!Array.isArray(d.blockers)) throw new Error("workflow-status.json: missing blockers"); + if (!Array.isArray(d.next_actions)) throw new Error("workflow-status.json: missing next_actions"); +} + +export async function readWorkflowStatus( + contextDir: string, +): Promise { + try { + const raw = await readFile(workflowStatusPath(contextDir), "utf-8"); + const data = JSON.parse(raw); + assertWorkflowStatusSummary(data); + return data; + } catch { + return null; + } +} + +export async function writeWorkflowStatus( + opts: WriteWorkflowStatusOptions, +): Promise { + const status: WorkflowStatusSummary = { + version: "1", + updated_at: Date.now(), + owner_member_id: opts.ownerMemberId, + summary: opts.summary.trim(), + blockers: (opts.blockers ?? []).map((v) => v.trim()).filter(Boolean), + next_actions: (opts.nextActions ?? []).map((v) => v.trim()).filter(Boolean), + }; + + if (!status.summary) { + throw new Error("workflow status summary must not be empty"); + } + + const root = agentsRoot(opts.contextDir); + const tmpDir = resolve(root, "tmp"); + await mkdir(dirname(workflowStatusPath(opts.contextDir)), { recursive: true }); + await atomicJsonWrite(tmpDir, workflowStatusPath(opts.contextDir), status); + return status; +} + +export async function clearWorkflowStatus(contextDir: string): Promise { + await rm(workflowStatusPath(contextDir), { force: true }); +} diff --git a/src/agents/task-store.ts b/src/agents/task-store.ts index ed1c02e..7bf6902 100644 --- a/src/agents/task-store.ts +++ b/src/agents/task-store.ts @@ -39,6 +39,7 @@ export function validateArtifactPath(p: string): void { export interface AddTaskOptions { contextDir: string; + taskId?: string; title: string; description: string; dependsOn?: string[]; @@ -48,7 +49,7 @@ export interface AddTaskOptions { } export async function addTask(opts: AddTaskOptions): Promise<{ taskId: string }> { - const taskId = randomUUID(); + const taskId = opts.taskId ?? randomUUID(); const now = Date.now(); const task: AgentTask = { @@ -64,6 +65,10 @@ export async function addTask(opts: AddTaskOptions): Promise<{ taskId: string }> claimed_by: null, claimed_at: null, completed_at: null, + superseded_at: null, + superseded_by: null, + supersede_reason: null, + replacement_task_id: null, artifacts: [], tags: opts.tags ?? [], requires_plan_approval: opts.requiresPlanApproval ?? false, @@ -101,7 +106,7 @@ export async function listTasks( ): Promise { const statuses: TaskStatus[] = status ? [status] - : ["pending", "in_progress", "completed", "blocked"]; + : ["pending", "in_progress", "completed", "blocked", "superseded"]; const tasks: AgentTask[] = []; @@ -130,6 +135,60 @@ export async function listTasks( return tasks; } +export function isTaskClaimableBy( + task: Pick, + memberId: string, +): boolean { + if (task.claimed_by) { + return task.claimed_by === memberId; + } + return task.assigned_to === null || task.assigned_to === memberId; +} + +export function areTaskDependenciesSatisfied( + task: Pick, + completedTaskIds: ReadonlySet, +): boolean { + return task.depends_on.every((dep) => completedTaskIds.has(dep)); +} + +async function readCompletedTaskIds(contextDir: string): Promise> { + const completedDir = tasksStatusDir(contextDir, "completed"); + let completedEntries: string[]; + try { + completedEntries = await readdir(completedDir); + } catch { + completedEntries = []; + } + + return new Set( + completedEntries + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(".json", "")), + ); +} + +export async function hasActionableTaskForMember( + contextDir: string, + memberId: string, +): Promise { + const inProgress = await listTasks(contextDir, "in_progress"); + if (inProgress.some((task) => task.claimed_by === memberId)) { + return true; + } + + const pending = await listTasks(contextDir, "pending"); + const claimablePending = pending.filter((task) => isTaskClaimableBy(task, memberId)); + if (claimablePending.length === 0) { + return false; + } + + const completedTaskIds = await readCompletedTaskIds(contextDir); + return claimablePending.some((task) => + areTaskDependenciesSatisfied(task, completedTaskIds), + ); +} + // --------------------------------------------------------------------------- // Claim task // --------------------------------------------------------------------------- @@ -173,21 +232,24 @@ export async function claimTask( return { ok: false, error: "Failed to read claimed task" }; } - // Check dependencies - if (task.depends_on.length > 0) { - const completedDir = tasksStatusDir(contextDir, "completed"); - let completedEntries: string[]; + if (!isTaskClaimableBy(task, memberId)) { try { - completedEntries = await readdir(completedDir); + await rename(destPath, srcPath); } catch { - completedEntries = []; + // best-effort rollback } - const completedIds = new Set( - completedEntries - .filter((f) => f.endsWith(".json")) - .map((f) => f.replace(".json", "")), - ); + if (task.assigned_to) { + return { + ok: false, + error: `Task is assigned to "${task.assigned_to}" and cannot be claimed by "${memberId}"`, + }; + } + return { ok: false, error: `Task cannot be claimed by "${memberId}"` }; + } + // Check dependencies + if (task.depends_on.length > 0) { + const completedIds = await readCompletedTaskIds(contextDir); const unmet = task.depends_on.filter((dep) => !completedIds.has(dep)); if (unmet.length > 0) { // Move to blocked/ @@ -280,6 +342,18 @@ export async function completeTask( return { ok: false, error: "Failed to read completed task" }; } + if (task.claimed_by && task.claimed_by !== memberId) { + try { + await rename(destPath, srcPath); + } catch { + // best-effort rollback + } + return { + ok: false, + error: `Task is claimed by "${task.claimed_by}" and cannot be completed by "${memberId}"`, + }; + } + // Validate artifact paths before writing if (artifacts) { for (const a of artifacts) { @@ -322,3 +396,76 @@ export async function completeTask( return { ok: true }; } + + +export async function supersedeTask( + contextDir: string, + taskId: string, + memberId: string, + reason?: string, + replacementTaskId?: string, +): Promise<{ ok: boolean; error?: string }> { + const activeStatuses: TaskStatus[] = ["pending", "in_progress", "blocked"]; + const supersededDir = tasksStatusDir(contextDir, "superseded"); + + await mkdir(supersededDir, { recursive: true }); + + const filename = `${taskId}.json`; + const destPath = resolve(supersededDir, filename); + + let fromStatus: TaskStatus | null = null; + for (const status of activeStatuses) { + const srcPath = resolve(tasksStatusDir(contextDir, status), filename); + try { + await rename(srcPath, destPath); + fromStatus = status; + break; + } catch { + // try next status + } + } + + if (fromStatus === null) { + return { ok: false, error: "Task not found in pending/, in_progress/, or blocked/" }; + } + + let task: AgentTask; + try { + const raw = await readFile(destPath, "utf-8"); + task = JSON.parse(raw) as AgentTask; + } catch { + return { ok: false, error: "Failed to read superseded task" }; + } + + const now = Date.now(); + task.status = "superseded"; + task.updated_at = now; + task.superseded_at = now; + task.superseded_by = memberId; + task.supersede_reason = reason ?? null; + task.replacement_task_id = replacementTaskId ?? null; + + const supersedeJson = JSON.stringify(task, null, 2); + if (Buffer.byteLength(supersedeJson, "utf-8") > MAX_TASK_BYTES) { + const rollbackPath = resolve(tasksStatusDir(contextDir, fromStatus), filename); + try { + await rename(destPath, rollbackPath); + } catch { /* best-effort */ } + return { ok: false, error: `Task file exceeds max size (${MAX_TASK_BYTES} bytes) after supersede update` }; + } + + const tmpDir = tasksTmp(contextDir); + await atomicJsonWrite(tmpDir, destPath, task); + + await appendTaskEvent(contextDir, { + ts: now, + type: "task_superseded", + task_id: taskId, + title: task.title, + by: memberId, + reason, + replacement_task_id: replacementTaskId, + }); + + return { ok: true }; +} diff --git a/src/agents/types.ts b/src/agents/types.ts index 85197aa..a243fcf 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -91,7 +91,7 @@ export interface AgentMessage { // Tasks // --------------------------------------------------------------------------- -export type TaskStatus = "pending" | "in_progress" | "completed" | "blocked"; +export type TaskStatus = "pending" | "in_progress" | "completed" | "blocked" | "superseded"; export interface AgentTask { version: "1"; @@ -106,6 +106,10 @@ export interface AgentTask { claimed_by: string | null; claimed_at: Timestamp | null; completed_at: Timestamp | null; + superseded_at: Timestamp | null; + superseded_by: string | null; + supersede_reason: string | null; + replacement_task_id: UUID | null; artifacts: string[]; tags: string[]; requires_plan_approval: boolean; @@ -140,7 +144,8 @@ export type TaskEventType = | "task_claimed" | "task_completed" | "task_blocked" - | "task_requeued"; + | "task_requeued" + | "task_superseded"; export interface TaskEvent { ts: Timestamp; @@ -148,4 +153,6 @@ export interface TaskEvent { task_id: UUID; title?: string; by?: string; + reason?: string; + replacement_task_id?: UUID; } diff --git a/src/core/agentcore.ts b/src/core/agentcore.ts index 48a991b..7bb8eb3 100644 --- a/src/core/agentcore.ts +++ b/src/core/agentcore.ts @@ -274,6 +274,7 @@ export class AgentCore { workerKind: payload.workerKind, workspaceRef: payload.workspaceRef, instructions: payload.instructions, + ...(payload.defaultArgs ? { defaultArgs: [...payload.defaultArgs] } : {}), ...(payload.model ? { model: payload.model } : {}), ...(payload.variant ? { variant: payload.variant } : {}), capabilities: payload.capabilities, diff --git a/src/tui/components/detail-pane.ts b/src/tui/components/detail-pane.ts index 0bcaf7d..c03f00b 100644 --- a/src/tui/components/detail-pane.ts +++ b/src/tui/components/detail-pane.ts @@ -2,6 +2,8 @@ import type { WorkflowUiState } from "../state-store.js"; import { isAgentStep, agentMemberId } from "../state-store.js"; import { renderOverviewTab } from "./tabs/overview-tab.js"; import { renderLogsTab } from "./tabs/logs-tab.js"; +import { renderRawLogsTab } from "./tabs/raw-logs-tab.js"; +import { renderUsageTab } from "./tabs/usage-tab.js"; import { renderDiffsTab } from "./tabs/diffs-tab.js"; import { renderResultTab } from "./tabs/result-tab.js"; import { renderCoreTab } from "./tabs/core-tab.js"; @@ -13,23 +15,27 @@ import { ansiFit } from "../ansi-utils.js"; const STEP_TABS = [ { key: "1", id: "overview", label: "Overview" }, - { key: "2", id: "logs", label: "Logs" }, - { key: "3", id: "diffs", label: "Diffs" }, - { key: "4", id: "result", label: "Result" }, - { key: "5", id: "core", label: "Core" }, - { key: "6", id: "agents", label: "Agents" }, - { key: "7", id: "chat", label: "Chat" }, - { key: "8", id: "help", label: "Help" }, + { key: "2", id: "logs", label: "Timeline" }, + { key: "3", id: "raw_logs", label: "Raw" }, + { key: "4", id: "diffs", label: "Diffs" }, + { key: "5", id: "usage", label: "Usage" }, + { key: "6", id: "result", label: "Result" }, + { key: "7", id: "core", label: "Core" }, + { key: "8", id: "agents", label: "Agents" }, + { key: "9", id: "chat", label: "Chat" }, + { key: "0", id: "help", label: "Help" }, ] as const; const AGENT_TABS = [ { key: "1", id: "chat", label: "Chat" }, { key: "2", id: "agent_overview", label: "Agent" }, - { key: "3", id: "logs", label: "Logs" }, - { key: "4", id: "agents", label: "Activity" }, - { key: "5", id: "result", label: "Result" }, - { key: "6", id: "core", label: "Core" }, - { key: "7", id: "help", label: "Help" }, + { key: "3", id: "logs", label: "Timeline" }, + { key: "4", id: "raw_logs", label: "Raw" }, + { key: "5", id: "usage", label: "Usage" }, + { key: "6", id: "agents", label: "Activity" }, + { key: "7", id: "result", label: "Result" }, + { key: "8", id: "core", label: "Core" }, + { key: "9", id: "help", label: "Help" }, ] as const; /** @@ -77,6 +83,7 @@ export function renderDetailPane(state: WorkflowUiState, width: number, height: const contentHeight = height - 2; // tab bar + separator const step = state.selectedStepId ? state.steps.get(state.selectedStepId) : undefined; const memberId = state.selectedStepId ? agentMemberId(state.selectedStepId) : undefined; + const rosterEntry = memberId ? state.agentRoster.get(memberId) : undefined; let content: string; switch (state.selectedTab) { @@ -84,11 +91,17 @@ export function renderDetailPane(state: WorkflowUiState, width: number, height: content = renderOverviewTab(state, step, width, contentHeight); break; case "agent_overview": - content = renderAgentOverviewTab(state, step, memberId ?? "", width, contentHeight); + content = renderAgentOverviewTab(state, step, rosterEntry, memberId ?? "", width, contentHeight); break; case "logs": content = renderLogsTab(step, width, contentHeight); break; + case "raw_logs": + content = renderRawLogsTab(step, width, contentHeight); + break; + case "usage": + content = renderUsageTab(state, step, memberId, width, contentHeight); + break; case "diffs": content = renderDiffsTab(step, width, contentHeight); break; diff --git a/src/tui/components/header.ts b/src/tui/components/header.ts index f8cbb22..e8e1e8a 100644 --- a/src/tui/components/header.ts +++ b/src/tui/components/header.ts @@ -67,12 +67,13 @@ function countStatuses(state: WorkflowUiState): { agentRunning: number; } { const stepCounts: Record = {}; - let agentTotal = 0; + const seenAgents = new Set(state.agentRosterOrder); let agentRunning = 0; for (const [stepId, step] of state.steps.entries()) { if (isAgentStep(stepId)) { - agentTotal++; + const memberId = stepId.slice("_agent:".length); + seenAgents.add(memberId); if (step.status === "RUNNING" || step.status === "CHECKING") { agentRunning++; } @@ -81,7 +82,7 @@ function countStatuses(state: WorkflowUiState): { } } - return { stepCounts, agentTotal, agentRunning }; + return { stepCounts, agentTotal: seenAgents.size, agentRunning }; } function formatCounts(counts: Record): string { diff --git a/src/tui/components/step-list.ts b/src/tui/components/step-list.ts index 01e940d..c2185cf 100644 --- a/src/tui/components/step-list.ts +++ b/src/tui/components/step-list.ts @@ -1,5 +1,5 @@ import type { WorkflowUiState, StepUiState } from "../state-store.js"; -import { agentMemberId, getAgentHintText } from "../state-store.js"; +import { agentMemberId, agentStepId, getAgentHintText } from "../state-store.js"; import { ansiFit, ansiTruncate, stripAnsi } from "../ansi-utils.js"; const SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"]; @@ -8,6 +8,7 @@ let spinnerIdx = 0; const STATUS_ICONS: Record = { PENDING: "\x1b[90m\u25CB\x1b[0m", READY: "\x1b[36m\u25CE\x1b[0m", + IDLE: "\x1b[36m\u25CE\x1b[0m", RUNNING: "\x1b[33m\u25CF\x1b[0m", CHECKING: "\x1b[35m\u25C9\x1b[0m", SUCCEEDED: "\x1b[32m\u2713\x1b[0m", @@ -15,6 +16,7 @@ const STATUS_ICONS: Record = { INCOMPLETE: "\x1b[90m\u25D1\x1b[0m", SKIPPED: "\x1b[90m\u2298\x1b[0m", CANCELLED: "\x1b[31m\u2298\x1b[0m", + DORMANT: "\x1b[90m\u263E\x1b[0m", }; export type LeftPaneEntry = @@ -29,16 +31,24 @@ export type LeftPaneEntry = export function buildLeftPaneEntries(state: WorkflowUiState): LeftPaneEntry[] { const steps: LeftPaneEntry[] = []; const agents: LeftPaneEntry[] = []; + const seenAgentIds = new Set(); for (const stepId of state.stepOrder) { const mid = agentMemberId(stepId); if (mid) { agents.push({ kind: "agent", stepId, memberId: mid }); + seenAgentIds.add(mid); } else { steps.push({ kind: "step", stepId }); } } + for (const memberId of state.agentRosterOrder) { + if (seenAgentIds.has(memberId)) continue; + agents.push({ kind: "agent", stepId: agentStepId(memberId), memberId }); + seenAgentIds.add(memberId); + } + const entries: LeftPaneEntry[] = [...steps]; if (agents.length > 0) { entries.push({ kind: "separator", label: "Agents" }); @@ -67,26 +77,35 @@ export function renderStepList(state: WorkflowUiState, width: number, height: nu const stepId = entry.stepId; const step = state.steps.get(stepId); - if (!step) continue; + const roster = entry.kind === "agent" ? state.agentRoster.get(entry.memberId) : undefined; + if (!step && !roster) continue; const isSelected = stepId === state.selectedStepId; const prefix = isSelected ? "\x1b[7m" : ""; const suffix = isSelected ? "\x1b[0m" : ""; - const icon = getStepIcon(step); + const effectiveStatus = + entry.kind === "agent" + ? getAgentStatus(step, roster?.role) + : (step?.status ?? "PENDING"); + const icon = getStepIcon(effectiveStatus); const displayName = entry.kind === "agent" - ? `\x1b[36m\u25C6\x1b[0m ${entry.memberId}` + ? `\x1b[36m\u25C6\x1b[0m ${roster?.name ?? entry.memberId}` : stepId; - const iter = step.maxIterations > 1 ? ` ${step.iteration}/${step.maxIterations}` : ""; - const duration = step.startedAt ? ` ${formatDuration(step)}` : ""; - const phase = step.phase ? ` \x1b[90m${step.phase}\x1b[0m` : ""; + const iter = step && step.maxIterations > 1 ? ` ${step.iteration}/${step.maxIterations}` : ""; + const duration = step?.startedAt ? ` ${formatDuration(step)}` : ""; + const phaseText = + entry.kind === "agent" + ? getAgentPhase(step, roster?.role) + : step?.phase; + const phase = phaseText ? ` \x1b[90m${phaseText}\x1b[0m` : ""; const base = ` ${icon} ${displayName}${iter}${duration}${phase}`; const fitted = ansiFit(ansiTruncate(base, w, { ellipsis: "..." }), w); lines.push(`${prefix}${fitted}${suffix}`); // Add hint subline for RUNNING agents - if (entry.kind === "agent" && step.status === "RUNNING") { + if (entry.kind === "agent" && step?.status === "RUNNING") { const hint = getAgentHintText(step); if (hint) { const plain = stripAnsi(hint); @@ -105,14 +124,34 @@ export function renderStepList(state: WorkflowUiState, width: number, height: nu return lines.slice(0, height).join("\n"); } -function getStepIcon(step: StepUiState): string { - if (step.status === "RUNNING") { +function getStepIcon(status: string): string { + if (status === "RUNNING") { return `\x1b[33m${SPINNER_FRAMES[spinnerIdx]}\x1b[0m`; } - if (step.status === "CHECKING") { + if (status === "CHECKING") { return `\x1b[35m${SPINNER_FRAMES[spinnerIdx]}\x1b[0m`; } - return STATUS_ICONS[step.status] ?? "\x1b[90m?\x1b[0m"; + return STATUS_ICONS[status] ?? "\x1b[90m?\x1b[0m"; +} + +function getAgentStatus(step: StepUiState | undefined, role?: string): string { + if (step && (step.status === "RUNNING" || step.status === "CHECKING")) { + return step.status; + } + if (role === "dormant") { + return "DORMANT"; + } + return "IDLE"; +} + +function getAgentPhase(step: StepUiState | undefined, role?: string): string | undefined { + if (step && (step.status === "RUNNING" || step.status === "CHECKING")) { + return step.phase; + } + if (role === "dormant") { + return "sleeping"; + } + return "idle"; } function formatDuration(step: StepUiState): string { diff --git a/src/tui/components/tabs/agent-overview-tab.ts b/src/tui/components/tabs/agent-overview-tab.ts index 5c85b43..ea69665 100644 --- a/src/tui/components/tabs/agent-overview-tab.ts +++ b/src/tui/components/tabs/agent-overview-tab.ts @@ -1,48 +1,116 @@ /** * Agent Overview tab — status, uptime, and recent activity for an agent. */ -import type { WorkflowUiState, StepUiState, AgentActivityEntry } from "../../state-store.js"; -import { sanitizeForTui, ansiTruncate } from "../../ansi-utils.js"; +import type { WorkflowUiState, StepUiState, AgentActivityEntry, AgentRosterEntry } from "../../state-store.js"; +import { sanitizeForTui, ansiTruncate, ansiWrap } from "../../ansi-utils.js"; export function renderAgentOverviewTab( state: WorkflowUiState, step: StepUiState | undefined, + roster: AgentRosterEntry | undefined, memberId: string, width: number, height: number, ): string { const lines: string[] = []; - if (!step) { + if (!step && !roster) { lines.push("\x1b[90mNo agent selected\x1b[0m"); return padLines(lines, height); } + const runtimeStatus = + step && (step.status === "RUNNING" || step.status === "CHECKING") + ? step.status + : (roster?.role === "dormant" ? "DORMANT" : "IDLE"); + const runtimePhase = + step && (step.status === "RUNNING" || step.status === "CHECKING") + ? step.phase + : (roster?.role === "dormant" ? "sleeping" : "idle"); + const displayName = roster?.name ?? memberId; + const runtime = state.agentRuntime.get(memberId); + // Agent identity - lines.push(`\x1b[1mAgent:\x1b[0m \x1b[36m${sanitizeForTui(memberId)}\x1b[0m`); + lines.push(`\x1b[1mAgent:\x1b[0m \x1b[36m${sanitizeForTui(displayName)}\x1b[0m`); + if (roster?.agentId) { + lines.push(`\x1b[1mProfile:\x1b[0m ${sanitizeForTui(roster.agentId)}`); + } + if (roster?.role) { + lines.push(`\x1b[1mRole:\x1b[0m ${sanitizeForTui(roster.role)}`); + } // Status - const statusColor = getStatusColor(step.status); - lines.push(`\x1b[1mStatus:\x1b[0m ${statusColor}${step.status}\x1b[0m`); + const statusColor = getStatusColor(runtimeStatus); + lines.push(`\x1b[1mStatus:\x1b[0m ${statusColor}${runtimeStatus}\x1b[0m`); // Uptime - if (step.startedAt) { + if (step?.startedAt) { const elapsed = (step.completedAt ?? Date.now()) - step.startedAt; lines.push(`\x1b[1mUptime:\x1b[0m ${formatMs(elapsed)}`); } // Phase - if (step.phase) { - lines.push(`\x1b[1mPhase:\x1b[0m \x1b[90m${sanitizeForTui(step.phase)}\x1b[0m`); + if (runtimePhase) { + lines.push(`\x1b[1mPhase:\x1b[0m \x1b[90m${sanitizeForTui(runtimePhase)}\x1b[0m`); } // Error - if (step.error) { + if (step?.error) { lines.push(`\x1b[31m\x1b[1mError:\x1b[0m ${sanitizeForTui(step.error)}`); } + lines.push(""); + lines.push("\x1b[1m\u2500\u2500 Runtime \u2500\u2500\x1b[0m"); + lines.push(`\x1b[1mDispatches:\x1b[0m ${runtime?.dispatchCount ?? 0}`); + lines.push(`\x1b[1mRestarts:\x1b[0m ${runtime?.restartCount ?? 0}`); + const totalEstimatedTokens = runtime?.totalEstimatedTokens ?? 0; + if (totalEstimatedTokens > 0) { + const lastTokens = runtime?.lastEstimatedTokens !== undefined + ? ` (last ${formatCompactCount(runtime.lastEstimatedTokens)})` + : ""; + lines.push(`\x1b[1mTokens:\x1b[0m ${formatCompactCount(totalEstimatedTokens)}${lastTokens}`); + } + const totalInstructionBytes = runtime?.totalInstructionBytes ?? 0; + if (totalInstructionBytes > 0) { + const lastBytes = runtime?.lastInstructionBytes !== undefined + ? ` (last ${formatBytes(runtime.lastInstructionBytes)})` + : ""; + lines.push(`\x1b[1mPrompt:\x1b[0m ${formatBytes(totalInstructionBytes)}${lastBytes}`); + } + if (runtime?.lastStartedAt) { + lines.push(`\x1b[1mLast Start:\x1b[0m ${formatTs(runtime.lastStartedAt)}`); + } + if (runtime?.lastStoppedAt) { + lines.push(`\x1b[1mLast Stop:\x1b[0m ${formatTs(runtime.lastStoppedAt)}`); + } + if (runtime?.currentlyDispatchingSince) { + lines.push(`\x1b[1mDispatch:\x1b[0m running since ${formatTs(runtime.currentlyDispatchingSince)}`); + } else if (runtime?.lastDispatchStartedAt) { + const finish = runtime.lastDispatchFinishedAt + ? ` -> ${formatTs(runtime.lastDispatchFinishedAt)}` + : ""; + const duration = runtime.lastDispatchDurationMs !== undefined + ? ` (${formatMs(runtime.lastDispatchDurationMs)})` + : ""; + lines.push(`\x1b[1mDispatch:\x1b[0m ${formatTs(runtime.lastDispatchStartedAt)}${finish}${duration}`); + } + if (runtime && runtime.totalDispatchActiveMs > 0) { + lines.push(`\x1b[1mBusy Time:\x1b[0m ${formatMs(runtime.totalDispatchActiveMs)}`); + } + + const instructionText = runtime?.currentInstructions ?? runtime?.lastInstructions; + if (instructionText) { + lines.push(""); + lines.push( + runtime?.currentInstructions + ? "\x1b[1m\u2500\u2500 Current Instructions \u2500\u2500\x1b[0m" + : "\x1b[1m\u2500\u2500 Last Instructions \u2500\u2500\x1b[0m", + ); + appendWrappedBlock(lines, instructionText, width, height); + } + // Current Output section - const stdoutLines = step.logs.stdout.lines(); + const stdoutLines = step?.logs.stdout.lines() ?? []; if (stdoutLines.length > 0) { lines.push(""); lines.push("\x1b[1m\u2500\u2500 Current Output \u2500\u2500\x1b[0m"); @@ -53,7 +121,7 @@ export function renderAgentOverviewTab( } // Modified Files section - if (step.patches.byFilePath.size > 0) { + if (step && step.patches.byFilePath.size > 0) { lines.push(""); lines.push("\x1b[1m\u2500\u2500 Modified Files \u2500\u2500\x1b[0m"); for (const [filePath, patchIds] of step.patches.byFilePath) { @@ -104,6 +172,9 @@ function formatActivityLine(entry: AgentActivityEntry, width: number): string { case "agent_task_completed": line = ` \x1b[90m${time}\x1b[0m \x1b[32m\u2713\x1b[0m Completed ${entry.label ?? entry.id}`; break; + case "agent_task_superseded": + line = ` \x1b[90m${time}\x1b[0m \x1b[35m\u21BA\x1b[0m Superseded ${entry.label ?? entry.id}`; + break; default: line = ` \x1b[90m${time}\x1b[0m ${entry.type}`; break; @@ -120,8 +191,10 @@ function formatActivityLine(entry: AgentActivityEntry, width: number): string { function getStatusColor(status: string): string { switch (status) { case "RUNNING": return "\x1b[33m"; + case "CHECKING": return "\x1b[35m"; case "SUCCEEDED": return "\x1b[32m"; case "FAILED": return "\x1b[31m"; + case "IDLE": return "\x1b[36m"; default: return "\x1b[90m"; } } @@ -135,6 +208,54 @@ function formatMs(ms: number): string { return `${h}h${m % 60}m`; } +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + const kib = bytes / 1024; + if (kib < 1024) return `${kib.toFixed(kib >= 10 ? 0 : 1)}KiB`; + const mib = kib / 1024; + return `${mib.toFixed(mib >= 10 ? 0 : 1)}MiB`; +} + +function formatCompactCount(value: number): string { + if (value < 1000) return String(value); + if (value < 10000) return `${(value / 1000).toFixed(1)}k`; + if (value < 1000000) return `${Math.round(value / 1000)}k`; + return `${(value / 1000000).toFixed(1)}M`; +} + +function formatTs(ts: number): string { + const d = new Date(ts); + return d.toISOString().replace("T", " ").slice(0, 19); +} + +function appendWrappedBlock( + lines: string[], + text: string, + width: number, + height: number, +): void { + const maxWidth = Math.max(8, width - 2); + const maxLines = Math.max(3, Math.min(18, height - lines.length - 8)); + const wrapped: string[] = []; + + for (const rawLine of sanitizeForTui(text).split("\n")) { + const chunks = ansiWrap(rawLine, maxWidth); + if (chunks.length === 0) { + wrapped.push(""); + continue; + } + wrapped.push(...chunks); + } + + const visible = wrapped.slice(0, maxLines); + for (const line of visible) { + lines.push(` ${line}`); + } + if (wrapped.length > maxLines) { + lines.push(" \x1b[90m...\x1b[0m"); + } +} + function padLines(lines: string[], height: number): string { while (lines.length < height) lines.push(""); return lines.slice(0, height).join("\n"); diff --git a/src/tui/components/tabs/agents-tab.ts b/src/tui/components/tabs/agents-tab.ts index 75f6302..6923243 100644 --- a/src/tui/components/tabs/agents-tab.ts +++ b/src/tui/components/tabs/agents-tab.ts @@ -4,8 +4,9 @@ * Shows recent messages and task events without message bodies or * task descriptions (secret-safe per docs/features/agents.md §10.1). */ -import type { WorkflowUiState, AgentActivityEntry } from "../../state-store.js"; -import { ansiWrap, sanitizeForTui } from "../../ansi-utils.js"; +import type { WorkflowUiState, AgentActivityEntry, AgentRuntimeStats, StepUiState } from "../../state-store.js"; +import { agentMemberId, agentStepId } from "../../state-store.js"; +import { ansiTruncate, ansiWrap, sanitizeForTui } from "../../ansi-utils.js"; export function renderAgentsTab( state: WorkflowUiState, @@ -14,22 +15,35 @@ export function renderAgentsTab( filterMemberId?: string, ): string { const lines: string[] = []; + const entries = filterMemberId + ? state.agentEntries.filter((e) => e.memberId === filterMemberId || e.targetMemberId === filterMemberId) + : state.agentEntries; + const hasActivity = entries.length > 0 || state.agentActivity.lines().length > 0; const title = filterMemberId ? `Activity: ${sanitizeForTui(filterMemberId)}` - : "Agent Messages"; + : "Agents"; lines.push(`\x1b[1m${title}\x1b[0m`); lines.push(""); - const entries = filterMemberId - ? state.agentEntries.filter((e) => e.memberId === filterMemberId || e.targetMemberId === filterMemberId) - : state.agentEntries; + if (!filterMemberId) { + const summaryLines = renderAgentSummarySection(state, width, height - lines.length, hasActivity); + for (const line of summaryLines) lines.push(line); + } + + if (hasActivity && lines.length < height) { + lines.push("\x1b[1mRecent Activity\x1b[0m"); + lines.push(""); + } + if (entries.length === 0 && state.agentActivity.lines().length === 0) { - lines.push("\x1b[90mNo agent activity yet\x1b[0m"); + if (lines.length <= 2) { + lines.push("\x1b[90mNo agent activity yet\x1b[0m"); + } return padLines(lines, height); } // Show structured entries (most recent last) - const target = Math.max(0, height - 3); + const target = Math.max(0, height - lines.length); const physical: string[] = []; // Use structured entries for richer display @@ -82,11 +96,240 @@ function formatEntry(entry: AgentActivityEntry): string { return `\x1b[90m${time}\x1b[0m \x1b[32m◆\x1b[0m \x1b[33m${entry.memberId}\x1b[0m claimed${topic}`; case "agent_task_completed": return `\x1b[90m${time}\x1b[0m \x1b[32m✓\x1b[0m \x1b[33m${entry.memberId}\x1b[0m completed${topic}`; + case "agent_task_superseded": + return `\x1b[90m${time}\x1b[0m \x1b[35m↺\x1b[0m \x1b[33m${entry.memberId}\x1b[0m superseded${topic}`; default: return `\x1b[90m${time}\x1b[0m ${entry.type} ${entry.memberId}${topic}`; } } +interface AgentSummaryRow { + memberId: string; + name: string; + role?: string; + status: string; + dispatchCount: number; + restartCount: number; + totalEstimatedTokens?: number; + totalInstructionBytes?: number; + busyMs: number; + lastStartedAt?: number; + lastStoppedAt?: number; + lastSeenAt?: number; +} + +function renderAgentSummarySection( + state: WorkflowUiState, + width: number, + remainingHeight: number, + hasActivity: boolean, +): string[] { + const summaries = collectAgentSummaryRows(state); + if (summaries.length === 0 || remainingHeight < 7) return []; + + const lines: string[] = []; + const sectionOverhead = 4; + const activityReserve = hasActivity ? 3 : 0; + const maxRows = Math.min( + summaries.length, + Math.max(1, remainingHeight - sectionOverhead - activityReserve), + ); + if (maxRows <= 0) return []; + + const showStop = width >= 96; + const showUsage = width >= 100; + const reservedWidth = showStop + ? (showUsage ? 63 : 44) + : (showUsage ? 54 : 35); + const agentWidth = Math.max(10, Math.min(showStop ? 24 : 32, width - reservedWidth)); + const header = + `${pad("Agent", agentWidth)} ${pad("State", 8)} ${padStart("Disp", 4)} ${padStart("Re", 3)}` + + (showUsage ? ` ${padStart("Tok", 7)} ${padStart("Prompt", 7)}` : "") + + ` ${padStart("Busy", 7)} ${pad("Start", 8)}` + + (showStop ? ` ${pad("Stop", 8)}` : ""); + + lines.push("\x1b[1mAgent Summary\x1b[0m"); + lines.push(ansiTruncate(header, Math.max(0, width), { ellipsis: "..." })); + lines.push(`\x1b[90m${"-".repeat(Math.max(0, Math.min(width, stripAnsiLength(header))))}\x1b[0m`); + + for (const row of summaries.slice(0, maxRows)) { + const line = + `${pad(truncatePlain(row.name, agentWidth), agentWidth)} ${pad(row.status, 8)} ${padStart(String(row.dispatchCount), 4)} ${padStart(String(row.restartCount), 3)}` + + (showUsage + ? ` ${padStart(formatCompactCount(row.totalEstimatedTokens), 7)} ${padStart(formatBytesCompact(row.totalInstructionBytes), 7)}` + : "") + + ` ${padStart(formatCompactMs(row.busyMs), 7)} ${pad(formatShortTs(row.lastStartedAt), 8)}` + + (showStop ? ` ${pad(formatShortTs(row.lastStoppedAt), 8)}` : ""); + lines.push(ansiTruncate(line, Math.max(0, width), { ellipsis: "..." })); + } + + if (summaries.length > maxRows) { + lines.push(`\x1b[90m+${summaries.length - maxRows} more agents\x1b[0m`); + } + + lines.push(""); + return lines; +} + +function collectAgentSummaryRows(state: WorkflowUiState): AgentSummaryRow[] { + const memberIds = new Set(); + + for (const memberId of state.agentRosterOrder) memberIds.add(memberId); + for (const memberId of state.agentRuntime.keys()) memberIds.add(memberId); + for (const stepId of state.stepOrder) { + const memberId = agentMemberId(stepId); + if (memberId) memberIds.add(memberId); + } + for (const entry of state.agentEntries) { + memberIds.add(entry.memberId); + if (entry.targetMemberId) memberIds.add(entry.targetMemberId); + } + + const rows: AgentSummaryRow[] = []; + for (const memberId of memberIds) { + const roster = state.agentRoster.get(memberId); + const runtime = state.agentRuntime.get(memberId); + const step = state.steps.get(agentStepId(memberId)); + const status = getAgentStatus(step, roster?.role); + const busyMs = getBusyMs(runtime); + const activityTs = getLatestActivityTs(state.agentEntries, memberId); + const lastSeenAt = maxTs( + activityTs, + runtime?.lastStartedAt, + runtime?.lastStoppedAt, + runtime?.lastDispatchFinishedAt, + runtime?.currentlyDispatchingSince, + step?.startedAt, + step?.completedAt, + ); + + rows.push({ + memberId, + name: roster?.name ?? memberId, + role: roster?.role, + status, + dispatchCount: runtime?.dispatchCount ?? 0, + restartCount: runtime?.restartCount ?? 0, + totalEstimatedTokens: runtime?.totalEstimatedTokens, + totalInstructionBytes: runtime?.totalInstructionBytes, + busyMs, + lastStartedAt: runtime?.lastStartedAt, + lastStoppedAt: runtime?.lastStoppedAt, + lastSeenAt, + }); + } + + return rows.sort((a, b) => { + const statusCmp = getStatusRank(a.status) - getStatusRank(b.status); + if (statusCmp !== 0) return statusCmp; + const seenCmp = (b.lastSeenAt ?? 0) - (a.lastSeenAt ?? 0); + if (seenCmp !== 0) return seenCmp; + return a.memberId.localeCompare(b.memberId); + }); +} + +function getAgentStatus(step: StepUiState | undefined, role?: string): string { + if (step && (step.status === "RUNNING" || step.status === "CHECKING")) { + return step.status; + } + if (role === "dormant") { + return "DORMANT"; + } + return "IDLE"; +} + +function getBusyMs(runtime: AgentRuntimeStats | undefined): number { + if (!runtime) return 0; + let total = runtime.totalDispatchActiveMs; + if (runtime.currentlyDispatchingSince !== undefined) { + total += Math.max(0, Date.now() - runtime.currentlyDispatchingSince); + } + return total; +} + +function getLatestActivityTs(entries: AgentActivityEntry[], memberId: string): number | undefined { + let latest: number | undefined; + for (const entry of entries) { + if (entry.memberId !== memberId && entry.targetMemberId !== memberId) continue; + latest = latest === undefined ? entry.ts : Math.max(latest, entry.ts); + } + return latest; +} + +function getStatusRank(status: string): number { + switch (status) { + case "RUNNING": + return 0; + case "CHECKING": + return 1; + case "IDLE": + return 2; + case "DORMANT": + return 3; + default: + return 4; + } +} + +function maxTs(...values: Array): number | undefined { + let latest: number | undefined; + for (const value of values) { + if (value === undefined) continue; + latest = latest === undefined ? value : Math.max(latest, value); + } + return latest; +} + +function truncatePlain(s: string, max: number): string { + if (s.length <= max) return s; + if (max <= 3) return s.slice(0, max); + return `${s.slice(0, max - 3)}...`; +} + +function pad(s: string, width: number): string { + return s.padEnd(width, " "); +} + +function padStart(s: string, width: number): string { + return s.padStart(width, " "); +} + +function formatCompactMs(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m${seconds % 60}s`; + const hours = Math.floor(minutes / 60); + if (hours < 100) return `${hours}h${minutes % 60}m`; + return `${hours}h`; +} + +function formatBytesCompact(bytes: number | undefined): string { + if (bytes === undefined || bytes <= 0) return "-"; + if (bytes < 1024) return `${bytes}B`; + const kib = bytes / 1024; + if (kib < 1000) return `${Math.round(kib)}K`; + const mib = kib / 1024; + return `${mib.toFixed(mib >= 10 ? 0 : 1)}M`; +} + +function formatCompactCount(value: number | undefined): string { + if (value === undefined || value <= 0) return "-"; + if (value < 1000) return String(value); + if (value < 10000) return `${(value / 1000).toFixed(1)}k`; + if (value < 1000000) return `${Math.round(value / 1000)}k`; + return `${(value / 1000000).toFixed(1)}M`; +} + +function formatShortTs(ts: number | undefined): string { + if (ts === undefined) return "-"; + return new Date(ts).toISOString().slice(11, 19); +} + +function stripAnsiLength(s: string): number { + return s.replace(/\x1b\[[0-9;]*m/g, "").length; +} + function truncate(s: string, max: number): string { // Collapse newlines to spaces for single-line preview const flat = s.replace(/\n/g, " ").replace(/\s+/g, " ").trim(); diff --git a/src/tui/components/tabs/help-tab.ts b/src/tui/components/tabs/help-tab.ts index ac9cb96..26fc194 100644 --- a/src/tui/components/tabs/help-tab.ts +++ b/src/tui/components/tabs/help-tab.ts @@ -8,7 +8,7 @@ export function renderHelpTab(_width: number, height: number): string { " \x1b[1mSpace\x1b[0m Toggle follow mode", " \x1b[1my\x1b[0m Copy visible tab", " \x1b[1mi\x1b[0m Start chat input (Chat tab)", - " \x1b[1m1-8\x1b[0m Switch tabs", + " \x1b[1m1-9,0\x1b[0m Switch tabs", " \x1b[1mCtrl+C\x1b[0m Cancel workflow", " \x1b[1mq\x1b[0m Quit", "", @@ -27,23 +27,27 @@ export function renderHelpTab(_width: number, height: number): string { "\x1b[1mStep Tabs\x1b[0m", "", " 1: Overview Step summary + workflow counts", - " 2: Logs stdout/stderr/progress streams", - " 3: Diffs File patches and diffs", - " 4: Result Final WorkerResult details", - " 5: Core Core stderr logs (supervised)", - " 6: Agents Agent messages & tasks (all agents)", - " 7: Chat Chat message history", - " 8: Help This help screen", + " 2: Timeline Human-readable agent/LLM event summary", + " 3: Raw Raw stdout/stderr/progress lines", + " 4: Diffs File patches and diffs", + " 5: Usage Token/cost/prompt usage summary", + " 6: Result Final WorkerResult details", + " 7: Core Core stderr logs (supervised)", + " 8: Agents Agent messages & tasks (all agents)", + " 9: Chat Chat message history", + " 0: Help This help screen", "", "\x1b[1mAgent Tabs\x1b[0m (shown when an agent is selected)", "", " 1: Chat Chat messages + inline input (i)", " 2: Agent Agent status, uptime, recent activity", - " 3: Logs Agent stdout/stderr/progress", - " 4: Activity Activity filtered to this agent", - " 5: Result Agent result details", - " 6: Core Core stderr logs", - " 7: Help This help screen", + " 3: Timeline Human-readable agent/LLM event summary", + " 4: Raw Raw stdout/stderr/progress lines", + " 5: Usage Agent usage and dispatch totals", + " 6: Activity Activity filtered to this agent", + " 7: Result Agent result details", + " 8: Core Core stderr logs", + " 9: Help This help screen", "", "\x1b[1mChat Input\x1b[0m (press i on Chat tab to activate)", "", diff --git a/src/tui/components/tabs/logs-tab.ts b/src/tui/components/tabs/logs-tab.ts index 922fb93..6a5fb9a 100644 --- a/src/tui/components/tabs/logs-tab.ts +++ b/src/tui/components/tabs/logs-tab.ts @@ -14,13 +14,13 @@ export function renderLogsTab( const entries: { channel: string; line: string }[] = []; for (const line of step.logs.stdout.lines()) { - entries.push({ channel: "out", line }); + entries.push(...expandLogEntry("worker_stdout", line)); } for (const line of step.logs.stderr.lines()) { - entries.push({ channel: "err", line }); + entries.push(...expandLogEntry("worker_stderr", line)); } for (const line of step.logs.progress.lines()) { - entries.push({ channel: "prg", line }); + entries.push(...expandLogEntry("worker_progress", line)); } if (entries.length === 0) { @@ -59,13 +59,356 @@ export function renderLogsTab( function getChannelPrefix(channel: string): string { switch (channel) { - case "out": return "\x1b[90m[out]\x1b[0m"; - case "err": return "\x1b[31m[err]\x1b[0m"; - case "prg": return "\x1b[36m[prg]\x1b[0m"; - default: return "\x1b[90m[???]\x1b[0m"; + case "worker_stdout": return "\x1b[90m[worker stdout]\x1b[0m"; + case "worker_stderr": return "\x1b[31m[worker stderr]\x1b[0m"; + case "worker_progress": return "\x1b[36m[worker progress]\x1b[0m"; + case "llm_message": return "\x1b[32m[message]\x1b[0m"; + case "llm_thinking": return "\x1b[35m[thinking]\x1b[0m"; + case "llm_tool_call": return "\x1b[33m[tool call]\x1b[0m"; + case "llm_usage": return "\x1b[36m[usage]\x1b[0m"; + case "llm_result": return "\x1b[32m[result]\x1b[0m"; + case "llm_turn": return "\x1b[34m[turn]\x1b[0m"; + case "roboppi_note": return "\x1b[90m[roboppi note]\x1b[0m"; + default: return "\x1b[90m[unknown]\x1b[0m"; } } +function expandLogEntry(channel: string, line: string): Array<{ channel: string; line: string }> { + if (channel === "worker_progress" && line === "(logs truncated)") { + return [{ channel: "roboppi_note", line: "roboppi truncated worker logs after the per-job limit was reached" }]; + } + + const parsed = tryParseJson(line); + if (!isRecord(parsed)) { + return [{ channel, line }]; + } + + const expanded = summarizeStructuredEvent(parsed); + return expanded.length > 0 ? expanded : [{ channel: inferUnknownChannel(parsed, channel), line: summarizeUnknownEvent(parsed) }]; +} + +function summarizeStructuredEvent(parsed: Record): Array<{ channel: string; line: string }> { + const entries: Array<{ channel: string; line: string }> = []; + const part = isRecord(parsed.part) ? parsed.part : undefined; + + if (part) { + entries.push(...summarizeNestedPart(parsed, part)); + } + + const progressMessage = typeof parsed.message === "string" ? parsed.message : undefined; + if (parsed.type === "progress" && progressMessage) { + entries.push({ channel: "worker_progress", line: progressMessage }); + } + + const resultSummary = getResultSummary(parsed); + if (resultSummary) { + entries.push({ channel: "llm_result", line: resultSummary }); + } + + const content = getContentItems(parsed); + for (const item of content) { + const summarized = summarizeContentItem(item); + if (summarized) entries.push(summarized); + } + + const usageSummary = getUsageSummary(parsed); + if (usageSummary) { + entries.push({ channel: "llm_usage", line: usageSummary }); + } + + if (entries.length === 0 && typeof parsed.result === "string") { + entries.push({ channel: "llm_result", line: parsed.result }); + } + + return entries; +} + +function summarizeNestedPart( + outer: Record, + part: Record, +): Array<{ channel: string; line: string }> { + const entries: Array<{ channel: string; line: string }> = []; + const rawType = typeof part.type === "string" + ? part.type + : (typeof outer.type === "string" ? outer.type : ""); + const type = rawType.replace(/_/g, "-"); + + if (type === "step-start") { + entries.push({ channel: "llm_turn", line: "llm turn started" }); + return entries; + } + + if (type === "step-finish") { + const reason = typeof part.reason === "string" ? part.reason : "completed"; + entries.push({ channel: "llm_turn", line: `llm turn finished: ${reason}` }); + + const usage = getPartTokensSummary(part); + const cost = typeof part.cost === "number" ? `cost=$${part.cost.toFixed(4)}` : undefined; + if (usage || cost) { + entries.push({ + channel: "llm_usage", + line: [cost, usage].filter(Boolean).join(" "), + }); + } + return entries; + } + + if (type === "tool") { + entries.push({ channel: "llm_tool_call", line: summarizeNestedToolUse(part) }); + const resultLine = summarizeNestedToolResult(part); + if (resultLine) { + entries.push({ channel: "llm_result", line: resultLine }); + } + return entries; + } + + return entries; +} + +function getContentItems(parsed: Record): Record[] { + const direct = parsed.content; + if (Array.isArray(direct)) { + return direct.filter(isRecord); + } + + const message = parsed.message; + if (isRecord(message) && Array.isArray(message.content)) { + return message.content.filter(isRecord); + } + + return []; +} + +function summarizeContentItem(item: Record): { channel: string; line: string } | undefined { + const type = typeof item.type === "string" ? item.type : undefined; + if (!type) return undefined; + + if (type === "thinking" && typeof item.thinking === "string") { + return { channel: "llm_thinking", line: truncateInline(item.thinking, 220) }; + } + + if (type === "text") { + const text = typeof item.text === "string" + ? item.text + : (typeof item.content === "string" ? item.content : undefined); + if (text) { + return { channel: "llm_message", line: truncateInline(text, 220) }; + } + } + + if (type === "tool_use") { + return { channel: "llm_tool_call", line: summarizeToolUse(item) }; + } + + if (type === "tool_result") { + const toolUseId = typeof item.tool_use_id === "string" ? item.tool_use_id : "tool"; + const content = typeof item.content === "string" ? truncateInline(item.content, 180) : "completed"; + return { channel: "llm_result", line: `${toolUseId}: ${content}` }; + } + + return undefined; +} + +function summarizeToolUse(item: Record): string { + const name = typeof item.name === "string" ? item.name : "tool"; + const input = isRecord(item.input) ? item.input : undefined; + const description = input && typeof input.description === "string" + ? truncateInline(input.description, 100) + : undefined; + + if (name === "Bash" && input && typeof input.command === "string") { + const command = truncateInline(input.command, 180); + return description ? `${name}: ${description} | ${command}` : `${name}: ${command}`; + } + + const path = input && typeof input.file_path === "string" + ? input.file_path + : input && typeof input.path === "string" + ? input.path + : undefined; + if (path) { + return description ? `${name}: ${description} | ${path}` : `${name}: ${path}`; + } + + return description ? `${name}: ${description}` : name; +} + +function summarizeNestedToolUse(part: Record): string { + const toolNameRaw = typeof part.tool === "string" + ? part.tool + : (typeof part.name === "string" ? part.name : "tool"); + const toolName = normalizeToolName(toolNameRaw); + const state = isRecord(part.state) ? part.state : undefined; + const input = state && isRecord(state.input) ? state.input : undefined; + const metadata = state && isRecord(state.metadata) ? state.metadata : undefined; + const title = typeof state?.title === "string" + ? state.title + : (typeof metadata?.description === "string" ? metadata.description : undefined); + const description = input && typeof input.description === "string" + ? input.description + : title; + + if (toolNameRaw.toLowerCase() === "bash" && input && typeof input.command === "string") { + const command = truncateInline(input.command, 180); + return description + ? `${toolName}: ${truncateInline(description, 100)} | ${command}` + : `${toolName}: ${command}`; + } + + const path = input && typeof input.file_path === "string" + ? input.file_path + : input && typeof input.path === "string" + ? input.path + : undefined; + if (path) { + return description + ? `${toolName}: ${truncateInline(description, 100)} | ${path}` + : `${toolName}: ${path}`; + } + + return description + ? `${toolName}: ${truncateInline(description, 100)}` + : toolName; +} + +function summarizeNestedToolResult(part: Record): string | undefined { + const toolNameRaw = typeof part.tool === "string" + ? part.tool + : (typeof part.name === "string" ? part.name : "tool"); + const toolName = normalizeToolName(toolNameRaw); + const state = isRecord(part.state) ? part.state : undefined; + if (!state) return undefined; + + const metadata = isRecord(state.metadata) ? state.metadata : undefined; + const status = typeof state.status === "string" ? state.status : undefined; + const exit = typeof metadata?.exit === "number" ? metadata.exit : undefined; + const output = typeof state.output === "string" + ? state.output + : (typeof metadata?.output === "string" ? metadata.output : undefined); + const time = isRecord(state.time) ? state.time : undefined; + const duration = typeof time?.start === "number" && typeof time?.end === "number" + ? `${Math.max(0, Math.round(time.end - time.start))}ms` + : undefined; + + const firstLine = output + ? truncateInline( + output + .split("\n") + .map((line) => line.trim()) + .find(Boolean) ?? "", + 180, + ) + : undefined; + + const summaryParts = [ + toolName, + status, + exit !== undefined ? `exit=${exit}` : undefined, + duration, + ].filter(Boolean); + + if (summaryParts.length === 0 && !firstLine) return undefined; + if (!firstLine) return summaryParts.join(" "); + return `${summaryParts.join(" ")} | ${firstLine}`; +} + +function getPartTokensSummary(part: Record): string | undefined { + const tokens = isRecord(part.tokens) ? part.tokens : undefined; + if (!tokens) return undefined; + + const fields: string[] = []; + addUsageField(fields, "tok", tokens.total); + addUsageField(fields, "in", tokens.input); + addUsageField(fields, "out", tokens.output); + addUsageField(fields, "rsn", tokens.reasoning); + + const cache = isRecord(tokens.cache) ? tokens.cache : undefined; + if (cache) { + addUsageField(fields, "cache_read", cache.read); + addUsageField(fields, "cache_write", cache.write); + } + + if (fields.length === 0) return undefined; + return fields.join(" "); +} + +function getUsageSummary(parsed: Record): string | undefined { + const usage = isRecord(parsed.usage) + ? parsed.usage + : (isRecord(parsed.message) && isRecord(parsed.message.usage) ? parsed.message.usage : undefined); + if (!usage) return undefined; + + const fields: string[] = []; + addUsageField(fields, "in", usage.input_tokens); + addUsageField(fields, "out", usage.output_tokens); + addUsageField(fields, "cache_read", usage.cache_read_input_tokens); + addUsageField(fields, "cache_write", usage.cache_creation_input_tokens); + if (fields.length === 0) return undefined; + return `tokens ${fields.join(" ")}`; +} + +function addUsageField(parts: string[], label: string, value: unknown): void { + if (typeof value !== "number" || !Number.isFinite(value)) return; + parts.push(`${label}=${formatCount(value)}`); +} + +function getResultSummary(parsed: Record): string | undefined { + const result = parsed.result; + if (typeof result === "string") { + return truncateInline(result, 220); + } + if (result !== undefined) { + return truncateInline(JSON.stringify(result), 220); + } + return undefined; +} + +function summarizeUnknownEvent(parsed: Record): string { + const type = typeof parsed.type === "string" ? parsed.type : "json"; + const role = typeof parsed.role === "string" ? ` role=${parsed.role}` : ""; + return `structured event: ${type}${role}`; +} + +function inferUnknownChannel(parsed: Record, fallback: string): string { + if (isRecord(parsed.part) || Array.isArray(parsed.content) || isRecord(parsed.message) || isRecord(parsed.usage)) { + return "llm_result"; + } + return fallback; +} + +function tryParseJson(line: string): unknown { + const trimmed = line.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return undefined; + try { + return JSON.parse(trimmed); + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function truncateInline(text: string, max: number): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + if (collapsed.length <= max) return collapsed; + if (max <= 3) return collapsed.slice(0, max); + return `${collapsed.slice(0, max - 3)}...`; +} + +function formatCount(value: number): string { + if (value < 1000) return String(value); + if (value < 10000) return `${(value / 1000).toFixed(1)}k`; + if (value < 1000000) return `${Math.round(value / 1000)}k`; + return `${(value / 1000000).toFixed(1)}M`; +} + +function normalizeToolName(name: string): string { + if (!name) return "tool"; + return name.charAt(0).toUpperCase() + name.slice(1); +} + function padLines(lines: string[], height: number): string { while (lines.length < height) lines.push(""); return lines.slice(0, height).join("\n"); diff --git a/src/tui/components/tabs/overview-tab.ts b/src/tui/components/tabs/overview-tab.ts index b5e6caa..ff49fd5 100644 --- a/src/tui/components/tabs/overview-tab.ts +++ b/src/tui/components/tabs/overview-tab.ts @@ -51,6 +51,29 @@ export function renderOverviewTab( ); } + lines.push(""); + lines.push("\x1b[1m\u2500\u2500 Summary \u2500\u2500\x1b[0m"); + + const summary = state.workflowStatusSummary; + if (!summary) { + lines.push("\x1b[90mNo agent summary yet\x1b[0m"); + } else { + lines.push(...wrapParagraph(summary.summary, w, "")); + lines.push( + ...wrapKeyValue( + `\x1b[1mUpdated:\x1b[0m `, + sanitizeForTui(`${formatTs(summary.updated_at)} by ${summary.owner_member_id}`), + w, + ), + ); + if (summary.blockers.length > 0) { + lines.push(...wrapList("Blockers", summary.blockers, w)); + } + if (summary.next_actions.length > 0) { + lines.push(...wrapList("Next", summary.next_actions, w)); + } + } + // Workflow summary lines.push(""); lines.push("\x1b[1m\u2500\u2500 Workflow Summary \u2500\u2500\x1b[0m"); @@ -90,6 +113,34 @@ function formatMs(ms: number): string { return `${m}m${s % 60}s`; } +function formatTs(ts: number): string { + return new Date(ts).toISOString().replace("T", " ").slice(0, 19); +} + +function wrapParagraph(text: string, width: number, indent: string): string[] { + const available = Math.max(0, width - ansiWidth(indent)); + const lines: string[] = []; + for (const raw of sanitizeForTui(text).split("\n")) { + const chunks = ansiWrap(raw, available); + if (chunks.length === 0) { + lines.push(indent); + continue; + } + for (const chunk of chunks) { + lines.push(indent + chunk); + } + } + return lines; +} + +function wrapList(title: string, items: string[], width: number): string[] { + const lines: string[] = [`\x1b[1m${title}:\x1b[0m`]; + for (const item of items) { + lines.push(...wrapParagraph(item, width, " - ")); + } + return lines; +} + function padLines(lines: string[], height: number): string { while (lines.length < height) lines.push(""); return lines.slice(0, height).join("\n"); diff --git a/src/tui/components/tabs/raw-logs-tab.ts b/src/tui/components/tabs/raw-logs-tab.ts new file mode 100644 index 0000000..b4a950f --- /dev/null +++ b/src/tui/components/tabs/raw-logs-tab.ts @@ -0,0 +1,56 @@ +import type { StepUiState } from "../../state-store.js"; +import { ansiWrap, ansiWidth, sanitizeForTui } from "../../ansi-utils.js"; + +export function renderRawLogsTab( + step: StepUiState | undefined, + width: number, + height: number, +): string { + if (!step) { + return padLines(["\x1b[90mNo step selected\x1b[0m"], height); + } + + const entries: Array<{ prefix: string; line: string }> = []; + for (const line of step.logs.stdout.lines()) { + entries.push({ prefix: "\x1b[90m[stdout]\x1b[0m", line }); + } + for (const line of step.logs.stderr.lines()) { + entries.push({ prefix: "\x1b[31m[stderr]\x1b[0m", line }); + } + for (const line of step.logs.progress.lines()) { + entries.push({ prefix: "\x1b[36m[progress]\x1b[0m", line }); + } + + if (entries.length === 0) { + return padLines(["\x1b[90mNo raw logs yet\x1b[0m"], height); + } + + const lines: string[] = []; + for (let idx = entries.length - 1; idx >= 0 && lines.length < height; idx--) { + const entry = entries[idx]!; + const prefixW = ansiWidth(entry.prefix); + const available = Math.max(0, width - prefixW - 1); + const safe = sanitizeForTui(entry.line); + const chunks = ansiWrap(safe, available); + + const block: string[] = []; + const contPrefix = " ".repeat(prefixW) + " "; + for (let i = 0; i < chunks.length; i++) { + const prefix = i === 0 ? `${entry.prefix} ` : contPrefix; + block.push(prefix + (chunks[i] ?? "")); + } + + const remaining = height - lines.length; + const take = block.slice(Math.max(0, block.length - remaining)); + for (let i = take.length - 1; i >= 0; i--) { + lines.unshift(take[i]!); + } + } + + return padLines(lines, height); +} + +function padLines(lines: string[], height: number): string { + while (lines.length < height) lines.push(""); + return lines.slice(0, height).join("\n"); +} diff --git a/src/tui/components/tabs/result-tab.ts b/src/tui/components/tabs/result-tab.ts index bfb5fb7..ffe10a4 100644 --- a/src/tui/components/tabs/result-tab.ts +++ b/src/tui/components/tabs/result-tab.ts @@ -37,6 +37,12 @@ export function renderResultTab( if (r.cost.wallTimeMs !== undefined) { lines.push(` Wall time: ${(r.cost.wallTimeMs / 1000).toFixed(1)}s`); } + if (r.cost.estimatedTokens !== undefined) { + lines.push(` Estimated tokens: ${r.cost.estimatedTokens}`); + } + if (r.cost.instructionBytes !== undefined) { + lines.push(` Prompt bytes: ${r.cost.instructionBytes}`); + } } if (r.artifacts && r.artifacts.length > 0) { diff --git a/src/tui/components/tabs/usage-tab.ts b/src/tui/components/tabs/usage-tab.ts new file mode 100644 index 0000000..f1979d8 --- /dev/null +++ b/src/tui/components/tabs/usage-tab.ts @@ -0,0 +1,94 @@ +import type { WorkflowUiState, StepUiState } from "../../state-store.js"; +import { sanitizeForTui } from "../../ansi-utils.js"; + +export function renderUsageTab( + state: WorkflowUiState, + step: StepUiState | undefined, + memberId: string | undefined, + _width: number, + height: number, +): string { + const lines: string[] = []; + const runtime = memberId ? state.agentRuntime.get(memberId) : undefined; + + if (!step && !runtime) { + return padLines(["\x1b[90mNo usage data yet\x1b[0m"], height); + } + + if (memberId) { + lines.push("\x1b[1mAgent Usage\x1b[0m"); + lines.push(""); + lines.push(`Dispatches: ${runtime?.dispatchCount ?? 0}`); + lines.push(`Restarts: ${runtime?.restartCount ?? 0}`); + if ((runtime?.totalEstimatedTokens ?? 0) > 0) { + lines.push(`Total estimated tokens: ${runtime?.totalEstimatedTokens ?? 0}`); + } + if (runtime?.lastEstimatedTokens !== undefined) { + lines.push(`Last dispatch tokens: ${runtime.lastEstimatedTokens}`); + } + if ((runtime?.totalInstructionBytes ?? 0) > 0) { + lines.push(`Total prompt bytes: ${runtime?.totalInstructionBytes ?? 0}`); + } + if (runtime?.lastInstructionBytes !== undefined) { + lines.push(`Last dispatch prompt bytes: ${runtime.lastInstructionBytes}`); + } + lines.push(`Busy time: ${formatMs(getBusyMs(runtime?.totalDispatchActiveMs ?? 0, runtime?.currentlyDispatchingSince))}`); + if (runtime?.lastStartedAt) { + lines.push(`Last start: ${formatTs(runtime.lastStartedAt)}`); + } + if (runtime?.lastStoppedAt) { + lines.push(`Last stop: ${formatTs(runtime.lastStoppedAt)}`); + } + lines.push(""); + } else { + lines.push("\x1b[1mStep Usage\x1b[0m"); + lines.push(""); + } + + if (step?.result?.cost) { + lines.push("\x1b[1mLast Result Cost\x1b[0m"); + lines.push(`Status: ${step.result.status}`); + lines.push(`Wall time: ${(step.result.cost.wallTimeMs / 1000).toFixed(1)}s`); + if (step.result.cost.estimatedTokens !== undefined) { + lines.push(`Estimated tokens: ${step.result.cost.estimatedTokens}`); + } + if (step.result.cost.instructionBytes !== undefined) { + lines.push(`Prompt bytes: ${step.result.cost.instructionBytes}`); + } + if (step.result.exitCode !== undefined) { + lines.push(`Exit code: ${step.result.exitCode}`); + } + } else if (step) { + lines.push("\x1b[90mNo completed result yet\x1b[0m"); + } + + if (step?.phase) { + lines.push(""); + lines.push(`Current phase: ${sanitizeForTui(step.phase)}`); + } + + return padLines(lines, height); +} + +function getBusyMs(total: number, currentlyDispatchingSince?: number): number { + if (currentlyDispatchingSince === undefined) return total; + return total + Math.max(0, Date.now() - currentlyDispatchingSince); +} + +function formatMs(ms: number): string { + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m${s % 60}s`; + const h = Math.floor(m / 60); + return `${h}h${m % 60}m`; +} + +function formatTs(ts: number): string { + return new Date(ts).toISOString().replace("T", " ").slice(0, 19); +} + +function padLines(lines: string[], height: number): string { + while (lines.length < height) lines.push(""); + return lines.slice(0, height).join("\n"); +} diff --git a/src/tui/exec-event.ts b/src/tui/exec-event.ts index ae2a018..3147fe0 100644 --- a/src/tui/exec-event.ts +++ b/src/tui/exec-event.ts @@ -113,4 +113,12 @@ export type ExecEvent = taskId: string; byMemberId: string; title?: string; + } + | { + type: "agent_task_superseded"; + ts: number; + teamId: string; + taskId: string; + byMemberId: string; + title?: string; }; diff --git a/src/tui/state-store.ts b/src/tui/state-store.ts index 7b4c632..26b0adc 100644 --- a/src/tui/state-store.ts +++ b/src/tui/state-store.ts @@ -1,6 +1,7 @@ import type { WorkerResult } from "../types/index.js"; import type { ExecEventSink, ExecEvent } from "./exec-event.js"; import type { ChatMessageEntry } from "./components/tabs/chat-tab.js"; +import type { WorkflowStatusSummary } from "../agents/status-store.js"; import { RingBuffer } from "./ring-buffer.js"; import { generateId } from "../types/index.js"; @@ -39,7 +40,7 @@ export interface StepUiState { export interface AgentActivityEntry { ts: number; - type: "agent_message_sent" | "agent_message_received" | "agent_task_claimed" | "agent_task_completed"; + type: "agent_message_sent" | "agent_message_received" | "agent_task_claimed" | "agent_task_completed" | "agent_task_superseded"; teamId: string; /** messageId for message events, taskId for task events */ id: string; @@ -53,6 +54,33 @@ export interface AgentActivityEntry { bodyPreview?: string; } +export interface AgentRosterEntry { + memberId: string; + name: string; + role: string; + agentId?: string; +} + +export interface AgentRuntimeStats { + memberId: string; + dispatchCount: number; + restartCount: number; + tokenSampleCount?: number; + totalEstimatedTokens?: number; + lastEstimatedTokens?: number; + totalInstructionBytes?: number; + lastInstructionBytes?: number; + lastStartedAt?: number; + lastStoppedAt?: number; + lastDispatchStartedAt?: number; + lastDispatchFinishedAt?: number; + lastDispatchDurationMs?: number; + totalDispatchActiveMs: number; + currentlyDispatchingSince?: number; + currentInstructions?: string; + lastInstructions?: string; +} + export interface WorkflowUiState { workflowId?: string; name?: string; @@ -67,11 +95,26 @@ export interface WorkflowUiState { stepOrder: string[]; selectedStepId?: string; followMode: "selected" | "running"; - selectedTab: "overview" | "logs" | "diffs" | "result" | "core" | "agents" | "chat" | "help" | "agent_overview"; + selectedTab: + | "overview" + | "logs" + | "raw_logs" + | "usage" + | "diffs" + | "result" + | "core" + | "agents" + | "chat" + | "help" + | "agent_overview"; coreLogs: RingBuffer; warnings: RingBuffer; agentActivity: RingBuffer; agentEntries: AgentActivityEntry[]; + agentRoster: Map; + agentRosterOrder: string[]; + agentRuntime: Map; + workflowStatusSummary?: WorkflowStatusSummary; chatMessages: ChatMessageEntry[]; /** Whether the chat input line is active (text input mode). */ chatInputActive: boolean; @@ -94,6 +137,10 @@ export function isAgentStep(stepId: string): boolean { return stepId.startsWith("_agent:"); } +export function agentStepId(memberId: string): string { + return `_agent:${memberId}`; +} + /** Extract the memberId from an agent pseudo-step id, or undefined if not an agent step. */ export function agentMemberId(stepId: string): string | undefined { if (!stepId.startsWith("_agent:")) return undefined; @@ -108,6 +155,17 @@ export function getAgentHintText(step: StepUiState): string { return ""; } +function arraysEqual(a?: string[], b?: string[]): boolean { + if (a === b) return true; + if (!a && !b) return true; + if (!a || !b) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + export class TuiStateStore implements ExecEventSink { readonly state: WorkflowUiState; dirty = false; @@ -140,6 +198,10 @@ export class TuiStateStore implements ExecEventSink { maxBytes: this.logLimitBytes, }), agentEntries: [], + agentRoster: new Map(), + agentRosterOrder: [], + agentRuntime: new Map(), + workflowStatusSummary: undefined, chatMessages: [], chatInputActive: false, chatInputBuffer: "", @@ -147,6 +209,54 @@ export class TuiStateStore implements ExecEventSink { }; } + syncAgentRoster(entries: AgentRosterEntry[]): void { + const sorted = [...entries].sort((a, b) => a.memberId.localeCompare(b.memberId)); + const prevOrder = this.state.agentRosterOrder; + let changed = prevOrder.length !== sorted.length; + + if (!changed) { + for (let i = 0; i < sorted.length; i++) { + const next = sorted[i]!; + const prevId = prevOrder[i]!; + const prev = this.state.agentRoster.get(prevId); + if ( + prevId !== next.memberId || + !prev || + prev.name !== next.name || + prev.role !== next.role || + prev.agentId !== next.agentId + ) { + changed = true; + break; + } + } + } + + if (!changed) return; + + this.state.agentRoster = new Map(sorted.map((entry) => [entry.memberId, entry])); + this.state.agentRosterOrder = sorted.map((entry) => entry.memberId); + this.dirty = true; + } + + syncWorkflowStatusSummary(summary: WorkflowStatusSummary | null | undefined): void { + const prev = this.state.workflowStatusSummary; + const next = summary ?? undefined; + + if ( + prev?.updated_at === next?.updated_at && + prev?.owner_member_id === next?.owner_member_id && + prev?.summary === next?.summary && + arraysEqual(prev?.blockers, next?.blockers) && + arraysEqual(prev?.next_actions, next?.next_actions) + ) { + return; + } + + this.state.workflowStatusSummary = next; + this.dirty = true; + } + emit(event: ExecEvent): void { this.dirty = true; @@ -179,6 +289,7 @@ export class TuiStateStore implements ExecEventSink { case "agent_message_received": case "agent_task_claimed": case "agent_task_completed": + case "agent_task_superseded": this.reduceAgentEvent(event); break; } @@ -248,12 +359,36 @@ export class TuiStateStore implements ExecEventSink { event: Extract, ): void { const step = this.getOrCreateStep(event.stepId); + const prevStatus = step.status; step.status = event.status; step.iteration = event.iteration; step.maxIterations = event.maxIterations; if (event.startedAt !== undefined) step.startedAt = event.startedAt; if (event.completedAt !== undefined) step.completedAt = event.completedAt; if (event.error !== undefined) step.error = event.error; + + const memberId = agentMemberId(event.stepId); + if (!memberId) return; + + const stats = this.getOrCreateAgentRuntime(memberId); + if (event.status === "RUNNING" && event.startedAt !== undefined) { + if (stats.lastStartedAt !== undefined || prevStatus !== "PENDING") { + stats.restartCount += 1; + } + stats.lastStartedAt = event.startedAt; + stats.lastStoppedAt = undefined; + } + + if (event.completedAt !== undefined && event.status !== "RUNNING") { + stats.lastStoppedAt = event.completedAt; + if (stats.currentlyDispatchingSince !== undefined) { + stats.lastDispatchFinishedAt = event.completedAt; + stats.lastDispatchDurationMs = Math.max(0, event.completedAt - stats.currentlyDispatchingSince); + stats.totalDispatchActiveMs += stats.lastDispatchDurationMs; + stats.currentlyDispatchingSince = undefined; + } + stats.currentInstructions = undefined; + } } private reduceStepPhase( @@ -261,6 +396,32 @@ export class TuiStateStore implements ExecEventSink { ): void { const step = this.getOrCreateStep(event.stepId); step.phase = event.phase; + + const memberId = agentMemberId(event.stepId); + if (!memberId) return; + + const stats = this.getOrCreateAgentRuntime(memberId); + if (event.phase === "executing") { + stats.dispatchCount += 1; + stats.lastDispatchStartedAt = event.at; + stats.currentlyDispatchingSince = event.at; + const instructions = typeof event.detail?.instructions === "string" + ? event.detail.instructions + : undefined; + if (instructions) { + stats.currentInstructions = instructions; + stats.lastInstructions = instructions; + } + return; + } + + if (event.phase === "ready" && stats.currentlyDispatchingSince !== undefined) { + stats.lastDispatchFinishedAt = event.at; + stats.lastDispatchDurationMs = Math.max(0, event.at - stats.currentlyDispatchingSince); + stats.totalDispatchActiveMs += stats.lastDispatchDurationMs; + stats.currentlyDispatchingSince = undefined; + stats.currentInstructions = undefined; + } } private reduceWorkerEvent( @@ -312,6 +473,23 @@ export class TuiStateStore implements ExecEventSink { ): void { const step = this.getOrCreateStep(event.stepId); step.result = event.result; + + const memberId = agentMemberId(event.stepId); + if (!memberId) return; + + const stats = this.getOrCreateAgentRuntime(memberId); + const estimatedTokens = event.result.cost.estimatedTokens; + if (estimatedTokens !== undefined) { + stats.tokenSampleCount = (stats.tokenSampleCount ?? 0) + 1; + stats.totalEstimatedTokens = (stats.totalEstimatedTokens ?? 0) + estimatedTokens; + stats.lastEstimatedTokens = estimatedTokens; + } + + const instructionBytes = event.result.cost.instructionBytes; + if (instructionBytes !== undefined) { + stats.totalInstructionBytes = (stats.totalInstructionBytes ?? 0) + instructionBytes; + stats.lastInstructionBytes = instructionBytes; + } } private reduceCoreLog( @@ -336,7 +514,7 @@ export class TuiStateStore implements ExecEventSink { } private reduceAgentEvent( - event: Extract, + event: Extract, ): void { let entry: AgentActivityEntry; let summary: string; @@ -390,6 +568,17 @@ export class TuiStateStore implements ExecEventSink { }; summary = `[agents] task completed by ${event.byMemberId}${event.title ? ` title=${event.title}` : ""}`; break; + case "agent_task_superseded": + entry = { + ts: event.ts, + type: event.type, + teamId: event.teamId, + id: event.taskId, + memberId: event.byMemberId, + label: event.title, + }; + summary = `[agents] task superseded by ${event.byMemberId}${event.title ? ` title=${event.title}` : ""}`; + break; } this.state.agentActivity.push(summary); @@ -425,4 +614,18 @@ export class TuiStateStore implements ExecEventSink { } } } + + private getOrCreateAgentRuntime(memberId: string): AgentRuntimeStats { + let stats = this.state.agentRuntime.get(memberId); + if (!stats) { + stats = { + memberId, + dispatchCount: 0, + restartCount: 0, + totalDispatchActiveMs: 0, + }; + this.state.agentRuntime.set(memberId, stats); + } + return stats; + } } diff --git a/src/tui/workflow-tui.ts b/src/tui/workflow-tui.ts index 358dbd7..1ce5886 100644 --- a/src/tui/workflow-tui.ts +++ b/src/tui/workflow-tui.ts @@ -10,10 +10,12 @@ import { copyToClipboard } from "./clipboard.js"; import { deliverMessage, readTeam, + readMembers, upsertMember, recvMessages, ackMessageByClaimToken, } from "../agents/store.js"; +import { readWorkflowStatus } from "../agents/status-store.js"; import { allDirs } from "../agents/paths.js"; import { mkdir } from "node:fs/promises"; @@ -29,6 +31,7 @@ export class WorkflowTui { private toast: { message: string; until: number } | null = null; private userRegistered = false; private chatPollTimer: ReturnType | null = null; + private rosterPollTimer: ReturnType | null = null; constructor( private readonly store: TuiStateStore, @@ -66,6 +69,11 @@ export class WorkflowTui { this.chatPollTimer = setInterval(() => { this.pollChatInbox().catch(() => {}); }, CHAT_POLL_INTERVAL_MS); + + this.rosterPollTimer = setInterval(() => { + this.pollAgentArtifacts().catch(() => {}); + }, CHAT_POLL_INTERVAL_MS); + this.pollAgentArtifacts().catch(() => {}); } /** @@ -86,6 +94,10 @@ export class WorkflowTui { } stop(): void { + if (this.rosterPollTimer) { + clearInterval(this.rosterPollTimer); + this.rosterPollTimer = null; + } if (this.chatPollTimer) { clearInterval(this.chatPollTimer); this.chatPollTimer = null; @@ -162,13 +174,13 @@ export class WorkflowTui { return; } - // Tab numbers 1-8 for tab switching (context-dependent) - if (key >= "1" && key <= "8") { + // Tab numbers 0-9 for tab switching (context-dependent) + if ((key >= "1" && key <= "9") || key === "0") { const isAgent = state.selectedStepId ? isAgentStep(state.selectedStepId) : false; const tabs = isAgent - ? ["chat", "agent_overview", "logs", "agents", "result", "core", "help"] as const - : ["overview", "logs", "diffs", "result", "core", "agents", "chat", "help"] as const; - const idx = parseInt(key) - 1; + ? ["chat", "agent_overview", "logs", "raw_logs", "usage", "agents", "result", "core", "help"] as const + : ["overview", "logs", "raw_logs", "diffs", "usage", "result", "core", "agents", "chat", "help"] as const; + const idx = key === "0" ? 9 : parseInt(key, 10) - 1; if (idx < tabs.length) { state.selectedTab = tabs[idx]!; this.store.dirty = true; @@ -358,6 +370,34 @@ export class WorkflowTui { } } + private async pollAgentArtifacts(): Promise { + const contextDir = this.store.state.contextDir; + if (!contextDir) return; + + try { + const membersConfig = await readMembers(contextDir); + this.store.syncAgentRoster( + membersConfig.members + .filter((member) => member.role !== "human" && member.role !== "team_lead") + .map((member) => ({ + memberId: member.member_id, + name: member.name || member.member_id, + role: member.role, + agentId: member.agent, + })), + ); + } catch { + // best-effort + } + + try { + const summary = await readWorkflowStatus(contextDir); + this.store.syncWorkflowStatusSummary(summary); + } catch { + // best-effort + } + } + private showToast(message: string): void { this.toast = { message, until: Date.now() + 2000 }; this.store.dirty = true; @@ -408,7 +448,7 @@ export class WorkflowTui { } // Default selection: first selectable entry - if (!state.selectedStepId && state.stepOrder.length > 0) { + if (!state.selectedStepId) { const entries = buildLeftPaneEntries(state); const first = entries.find((e) => e.kind !== "separator"); state.selectedStepId = first ? first.stepId : state.stepOrder[0]; diff --git a/src/types/worker-result.ts b/src/types/worker-result.ts index b1b68ed..f0835ef 100644 --- a/src/types/worker-result.ts +++ b/src/types/worker-result.ts @@ -20,6 +20,7 @@ export interface Observation { export interface WorkerCost { estimatedTokens?: number; + instructionBytes?: number; wallTimeMs: number; } diff --git a/src/types/worker-task-job-payload.ts b/src/types/worker-task-job-payload.ts index 3d750a7..9c83808 100644 --- a/src/types/worker-task-job-payload.ts +++ b/src/types/worker-task-job-payload.ts @@ -10,6 +10,7 @@ export interface WorkerTaskJobPayload { workerKind: WorkerKind; workspaceRef: string; instructions: string; + defaultArgs?: string[]; model?: string; variant?: string; capabilities: WorkerCapability[]; @@ -43,6 +44,13 @@ export function isWorkerTaskJobPayload(value: unknown): value is WorkerTaskJobPa if (typeof v.workspaceRef !== "string") return false; if (typeof v.instructions !== "string") return false; + if (v.defaultArgs !== undefined) { + if (!Array.isArray(v.defaultArgs)) return false; + for (const arg of v.defaultArgs) { + if (typeof arg !== "string") return false; + } + } + if (v.model !== undefined && typeof v.model !== "string") return false; if (v.variant !== undefined && typeof v.variant !== "string") return false; diff --git a/src/types/worker-task.ts b/src/types/worker-task.ts index ec5632a..3bd5901 100644 --- a/src/types/worker-task.ts +++ b/src/types/worker-task.ts @@ -33,6 +33,8 @@ export interface WorkerTask { workerKind: WorkerKind; workspaceRef: string; instructions: string; + /** Optional CLI args forwarded to the worker adapter. */ + defaultArgs?: string[]; /** Optional model identifier for LLM-backed workers (adapter-specific format). */ model?: string; /** Optional model variant / reasoning effort hint (worker-specific). */ diff --git a/src/worker/adapters/claude-code-adapter.ts b/src/worker/adapters/claude-code-adapter.ts index 1fe8c03..3e2caf0 100644 --- a/src/worker/adapters/claude-code-adapter.ts +++ b/src/worker/adapters/claude-code-adapter.ts @@ -46,7 +46,7 @@ export function buildArgs( task: WorkerTask, config: Required ): string[] { - const args: string[] = [...config.defaultArgs]; + const args: string[] = [...config.defaultArgs, ...(task.defaultArgs ?? [])]; if (task.model) { // If defaultArgs already specify a model, prefer the task-level model. diff --git a/src/worker/adapters/codex-cli-adapter.ts b/src/worker/adapters/codex-cli-adapter.ts index 5d5f1d1..e72dd00 100644 --- a/src/worker/adapters/codex-cli-adapter.ts +++ b/src/worker/adapters/codex-cli-adapter.ts @@ -11,6 +11,48 @@ export interface CodexCliAdapterConfig { gracePeriodMs?: number; } +function normalizeLegacyCodexArgs(args: string[]): string[] { + let needsBypass = false; + const preserved: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === "--dangerously-bypass-approvals-and-sandbox") { + needsBypass = true; + continue; + } + if (arg === "--ask-for-approval") { + const value = args[i + 1]; + if (value === "never") { + needsBypass = true; + i++; + continue; + } + } + preserved.push(arg); + } + + if (!needsBypass) { + return preserved; + } + + const normalized: string[] = []; + for (let i = 0; i < preserved.length; i++) { + const arg = preserved[i]!; + if (arg === "--full-auto") { + continue; + } + if (arg === "--sandbox") { + i++; // drop the sandbox value as well; bypass supersedes sandbox selection + continue; + } + normalized.push(arg); + } + + normalized.push("--dangerously-bypass-approvals-and-sandbox"); + return normalized; +} + export class CodexCliAdapter extends BaseProcessAdapter { readonly kind = WorkerKind.CODEX_CLI; private readonly config: Required; @@ -31,12 +73,16 @@ export class CodexCliAdapter extends BaseProcessAdapter { // Codex CLI supports non-interactive execution via `codex exec`. // We pass instructions as the positional [PROMPT] argument. const args: string[] = [this.config.codexCommand, "exec"]; + const defaultArgs = normalizeLegacyCodexArgs([ + ...this.config.defaultArgs, + ...(task.defaultArgs ?? []), + ]); // Default args, with optional model override. if (task.model) { // Prefer task-level model over config default args. - for (let i = 0; i < this.config.defaultArgs.length; i++) { - const a = this.config.defaultArgs[i]!; + for (let i = 0; i < defaultArgs.length; i++) { + const a = defaultArgs[i]!; if (a === "--model") { i++; // skip value continue; @@ -46,7 +92,7 @@ export class CodexCliAdapter extends BaseProcessAdapter { } args.push("--model", normalizeCodexModel(task.model)); } else { - args.push(...this.config.defaultArgs); + args.push(...defaultArgs); } // Always use JSONL output for reliable parsing/logging. @@ -66,8 +112,11 @@ export class CodexCliAdapter extends BaseProcessAdapter { const hasRunCommands = task.capabilities.includes(WorkerCapability.RUN_COMMANDS) || task.capabilities.includes(WorkerCapability.RUN_TESTS); + const hasBypass = args.includes("--dangerously-bypass-approvals-and-sandbox"); - if (hasWrite && hasRunCommands) { + if (hasBypass) { + // Current Codex CLI bypass mode already implies no sandbox / no approvals. + } else if (hasWrite && hasRunCommands) { // Low-friction sandboxed auto execution. if (!args.includes("--full-auto")) { args.push("--full-auto"); diff --git a/src/worker/adapters/opencode-adapter.ts b/src/worker/adapters/opencode-adapter.ts index 961af3f..5439f19 100644 --- a/src/worker/adapters/opencode-adapter.ts +++ b/src/worker/adapters/opencode-adapter.ts @@ -15,7 +15,8 @@ export function buildArgs( task: WorkerTask, config: Required, ): string[] { - const args: string[] = ["run", "--format", "json", ...config.defaultArgs]; + const mergedDefaultArgs = [...config.defaultArgs, ...(task.defaultArgs ?? [])]; + const args: string[] = ["run", "--format", "json", ...mergedDefaultArgs]; if (task.model) { // If defaultArgs already specify a model, prefer the task-level model. diff --git a/src/workflow/agent-catalog.ts b/src/workflow/agent-catalog.ts index d65cbe5..7d1751a 100644 --- a/src/workflow/agent-catalog.ts +++ b/src/workflow/agent-catalog.ts @@ -11,6 +11,9 @@ export interface AgentProfile { /** Default worker kind when referenced via step.agent. */ worker?: AgentWorkerKind; + /** Optional CLI arguments forwarded to the selected resident worker adapter. */ + defaultArgs?: string[]; + /** Default model identifier (adapter-specific). */ model?: string; @@ -81,6 +84,18 @@ function validateOptionalString(value: unknown, field: string): void { } } +function validateOptionalStringArray(value: unknown, field: string): void { + if (value === undefined) return; + if (!Array.isArray(value)) { + throw new AgentCatalogParseError(`"${field}" must be an array`); + } + for (const item of value) { + if (typeof item !== "string") { + throw new AgentCatalogParseError(`"${field}" must contain only strings`); + } + } +} + function validateOptionalWorker(value: unknown, field: string): void { if (value === undefined) return; if (typeof value !== "string" || !VALID_WORKERS.has(value as AgentWorkerKind)) { @@ -164,6 +179,7 @@ export function parseAgentCatalog(yamlContent: string): AgentCatalog { validateOptionalNonEmptyString(obj["description"], `agents.${agentId}.description`); validateOptionalWorker(obj["worker"], `agents.${agentId}.worker`); + validateOptionalStringArray(obj["defaultArgs"], `agents.${agentId}.defaultArgs`); validateOptionalNonEmptyString(obj["model"], `agents.${agentId}.model`); validateOptionalNonEmptyString(obj["variant"], `agents.${agentId}.variant`); // Allow empty string to intentionally clear inherited base_instructions. diff --git a/src/workflow/core-ipc-step-runner.ts b/src/workflow/core-ipc-step-runner.ts index 7f22953..36821f3 100644 --- a/src/workflow/core-ipc-step-runner.ts +++ b/src/workflow/core-ipc-step-runner.ts @@ -456,6 +456,7 @@ export class CoreIpcStepRunner implements StepRunner { workerKind: task.workerKind, workspaceRef: task.workspaceRef, instructions: task.instructions, + ...(task.defaultArgs ? { defaultArgs: [...task.defaultArgs] } : {}), ...(task.model ? { model: task.model } : {}), ...(task.variant ? { variant: task.variant } : {}), capabilities: task.capabilities, diff --git a/src/workflow/executor.ts b/src/workflow/executor.ts index 2998e66..a0dd2cc 100644 --- a/src/workflow/executor.ts +++ b/src/workflow/executor.ts @@ -2,7 +2,7 @@ import { ErrorClass } from "../types/common.js"; import type { Artifact, Observation, WorkerCost } from "../types/worker-result.js"; import { mkdir, readFile, writeFile, cp, stat, rm } from "node:fs/promises"; import path from "node:path"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import type { WorkflowDefinition, StepDefinition, @@ -490,7 +490,7 @@ export class WorkflowExecutor { ([memberId, memberCfg]) => ({ member_id: memberId, name: memberId, - role: memberId === "lead" ? "team_lead" : "member", + role: memberId === "lead" ? "team_lead" : (memberCfg.role ?? "member"), worker: undefined, capabilities: undefined, workspace: undefined, @@ -521,12 +521,31 @@ export class WorkflowExecutor { // Seed initial tasks from DSL config if (agentsConfig.tasks) { + const seededTaskIds = new Map(); for (const taskDef of agentsConfig.tasks) { + if (taskDef.id) { + seededTaskIds.set(taskDef.id, randomUUID()); + } + } + for (const taskDef of agentsConfig.tasks) { + const dependsOn = taskDef.depends_on?.map((dep) => { + const mapped = seededTaskIds.get(dep); + if (!mapped) { + throw new Error(`Unknown seed task dependency "${dep}"`); + } + return mapped; + }); await addAgentsTask({ contextDir: this.contextManager.contextDir, + ...(taskDef.id ? { taskId: seededTaskIds.get(taskDef.id) } : {}), title: taskDef.title, description: taskDef.description, assignedTo: taskDef.assigned_to, + ...(dependsOn && dependsOn.length > 0 ? { dependsOn } : {}), + ...(taskDef.tags && taskDef.tags.length > 0 ? { tags: taskDef.tags } : {}), + ...(taskDef.requires_plan_approval !== undefined + ? { requiresPlanApproval: taskDef.requires_plan_approval } + : {}), }); } } @@ -864,6 +883,9 @@ export class WorkflowExecutor { if (resolvedAgent.worker === undefined && profile.worker !== undefined) { resolvedAgent.worker = profile.worker; } + if (resolvedAgent.defaultArgs === undefined && profile.defaultArgs !== undefined) { + resolvedAgent.defaultArgs = [...profile.defaultArgs]; + } if (resolvedAgent.model === undefined && profile.model !== undefined) { resolvedAgent.model = profile.model; } diff --git a/src/workflow/management/types.ts b/src/workflow/management/types.ts index 7b7da80..f949abd 100644 --- a/src/workflow/management/types.ts +++ b/src/workflow/management/types.ts @@ -88,6 +88,8 @@ export interface ManagementAgentConfig { agent?: string; /** Engine type: "worker" (default, file-based) or "pi" (Pi SDK in-process). */ engine?: "worker" | "pi"; + /** Optional CLI args forwarded to the resolved worker adapter. */ + defaultArgs?: string[]; model?: string; capabilities?: ("READ" | "EDIT" | "RUN_TESTS" | "RUN_COMMANDS" | "MAILBOX" | "TASKS")[]; timeout?: DurationString; diff --git a/src/workflow/management/worker-engine.ts b/src/workflow/management/worker-engine.ts index 0cbcfc8..981f4ea 100644 --- a/src/workflow/management/worker-engine.ts +++ b/src/workflow/management/worker-engine.ts @@ -94,6 +94,7 @@ export class WorkerEngine implements ManagementAgentEngine { // Build the management worker step definition const mgmtStepDef: StepDefinition = { worker: this.agentConfig.worker ?? "OPENCODE", + defaultArgs: this.agentConfig.defaultArgs, model: this.agentConfig.model, instructions: this.agentConfig.base_instructions ?? "You are a workflow management agent.", capabilities: this.agentConfig.capabilities ?? ["READ"], diff --git a/src/workflow/parser.ts b/src/workflow/parser.ts index d3adeed..799ba75 100644 --- a/src/workflow/parser.ts +++ b/src/workflow/parser.ts @@ -381,6 +381,9 @@ function resolveTaskLikeWithAgent( if (resolved["worker"] === undefined && agent.worker !== undefined) { resolved["worker"] = agent.worker; } + if (resolved["defaultArgs"] === undefined && agent.defaultArgs !== undefined) { + resolved["defaultArgs"] = [...agent.defaultArgs]; + } if (resolved["model"] === undefined && agent.model !== undefined) { resolved["model"] = agent.model; } @@ -429,6 +432,7 @@ function validateCompletionCheck(check: unknown, stepId: string, agents?: AgentC assertString(obj["instructions"], `steps.${stepId}.completion_check.instructions`); validateWorker(obj["worker"], `steps.${stepId}.completion_check.worker`); validateCapabilities(obj["capabilities"], `steps.${stepId}.completion_check.capabilities`); + validateOptionalStringArray(obj["defaultArgs"], `steps.${stepId}.completion_check.defaultArgs`); validateOptionalString(obj["model"], `steps.${stepId}.completion_check.model`); validateOptionalString(obj["variant"], `steps.${stepId}.completion_check.variant`); @@ -453,6 +457,7 @@ function validateSubworkflowStep(stepId: string, obj: Record): "agent", "workspace", "worker", + "defaultArgs", "model", "instructions", "capabilities", @@ -611,6 +616,7 @@ function validateStep(stepId: string, step: unknown, agents?: AgentCatalog): Ste assertString(obj["instructions"], `steps.${stepId}.instructions`); validateWorker(obj["worker"], `steps.${stepId}.worker`); validateCapabilities(obj["capabilities"], `steps.${stepId}.capabilities`); + validateOptionalStringArray(obj["defaultArgs"], `steps.${stepId}.defaultArgs`); validateOptionalString(obj["model"], `steps.${stepId}.model`); validateOptionalString(obj["variant"], `steps.${stepId}.variant`); @@ -773,6 +779,7 @@ function validateManagementAgent(value: unknown, fieldPrefix: string, agents?: A } validateOptionalString(obj["model"], `${fieldPrefix}.model`); + validateOptionalStringArray(obj["defaultArgs"], `${fieldPrefix}.defaultArgs`); if (obj["capabilities"] !== undefined) { validateCapabilities(obj["capabilities"], `${fieldPrefix}.capabilities`); } @@ -979,6 +986,7 @@ function validateAgents(value: unknown): AgentsWorkflowConfig | undefined { } const member = memberVal as Record; assertString(member["agent"], `agents.members.${memberId}.agent`); + validateOptionalString(member["role"], `agents.members.${memberId}.role`); } } @@ -988,19 +996,43 @@ function validateAgents(value: unknown): AgentsWorkflowConfig | undefined { throw new WorkflowParseError("agents.tasks must be an array"); } + const tasks = obj["tasks"] as unknown[]; + const usesTaskDependencies = tasks.some((taskVal) => { + if (typeof taskVal !== "object" || taskVal === null || Array.isArray(taskVal)) { + return false; + } + const task = taskVal as Record; + return Array.isArray(task["depends_on"]) && task["depends_on"].length > 0; + }); + // Collect declared member ids for assigned_to validation const declaredMembers = new Set( obj["members"] && typeof obj["members"] === "object" && !Array.isArray(obj["members"]) ? Object.keys(obj["members"] as Record) : [], ); + const declaredTaskIds = new Set(); - for (let i = 0; i < obj["tasks"].length; i++) { - const taskVal = obj["tasks"][i]; + for (let i = 0; i < tasks.length; i++) { + const taskVal = tasks[i]; if (typeof taskVal !== "object" || taskVal === null || Array.isArray(taskVal)) { throw new WorkflowParseError(`agents.tasks[${i}] must be an object`); } const task = taskVal as Record; + if (task["id"] !== undefined) { + assertPathSegment(task["id"], `agents.tasks[${i}].id`); + const taskId = task["id"] as string; + if (declaredTaskIds.has(taskId)) { + throw new WorkflowParseError( + `agents.tasks[${i}].id duplicates "${taskId}"`, + ); + } + declaredTaskIds.add(taskId); + } else if (usesTaskDependencies) { + throw new WorkflowParseError( + `agents.tasks[${i}].id is required when agents.tasks uses depends_on`, + ); + } assertString(task["title"], `agents.tasks[${i}].title`); assertString(task["description"], `agents.tasks[${i}].description`); @@ -1012,6 +1044,30 @@ function validateAgents(value: unknown): AgentsWorkflowConfig | undefined { ); } } + + validateOptionalStringArray(task["depends_on"], `agents.tasks[${i}].depends_on`); + validateOptionalStringArray(task["tags"], `agents.tasks[${i}].tags`); + validateOptionalBoolean(task["requires_plan_approval"], `agents.tasks[${i}].requires_plan_approval`); + } + + if (usesTaskDependencies) { + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i] as Record; + const taskId = task["id"] as string; + const deps = Array.isArray(task["depends_on"]) ? task["depends_on"] as string[] : []; + for (const dep of deps) { + if (!declaredTaskIds.has(dep)) { + throw new WorkflowParseError( + `agents.tasks[${i}].depends_on references unknown task "${dep}". Declared task ids: ${[...declaredTaskIds].join(", ") || "(none)"}`, + ); + } + if (dep === taskId) { + throw new WorkflowParseError( + `agents.tasks[${i}].depends_on must not reference itself ("${taskId}")`, + ); + } + } + } } } diff --git a/src/workflow/resolve-worker-task.ts b/src/workflow/resolve-worker-task.ts index d4ae165..dbb4194 100644 --- a/src/workflow/resolve-worker-task.ts +++ b/src/workflow/resolve-worker-task.ts @@ -42,6 +42,7 @@ export interface ResolvedWorkerTaskDef { workerKind: WorkerKind; workspaceRef: string; instructions: string; + defaultArgs?: string[]; model?: string; variant?: string; capabilities: WorkerCapability[]; @@ -59,6 +60,7 @@ type TaskLikeDef = { worker?: StepDefinition["worker"]; workspace?: string; instructions?: string; + defaultArgs?: string[]; capabilities?: StepDefinition["capabilities"]; timeout?: StepDefinition["timeout"]; max_steps?: StepDefinition["max_steps"]; @@ -127,6 +129,7 @@ export function resolveTaskLike( workerKind: toWorkerKind(def.worker), workspaceRef, instructions: def.instructions ?? "", + ...(Array.isArray(def.defaultArgs) ? { defaultArgs: [...def.defaultArgs] } : {}), capabilities: toWorkerCapabilities(def.capabilities ?? []), timeoutMs, ...(def.model ? { model: def.model } : {}), @@ -159,6 +162,7 @@ export function buildWorkerTask( workerKind: resolved.workerKind, workspaceRef: resolved.workspaceRef, instructions: resolved.instructions, + ...(resolved.defaultArgs ? { defaultArgs: [...resolved.defaultArgs] } : {}), capabilities: resolved.capabilities, outputMode: OutputMode.BATCH, ...(resolved.model ? { model: resolved.model } : {}), diff --git a/src/workflow/run.ts b/src/workflow/run.ts index 2d90da8..c2e916c 100644 --- a/src/workflow/run.ts +++ b/src/workflow/run.ts @@ -359,13 +359,17 @@ Options: printBranchContext(branchContext); console.log(""); + const supervisedCoreEntryPoint = supervised + ? resolveCoreEntryPointForSupervised(coreEntryPointOverride) + : undefined; + // Default supervised IPC transport. // // Probe child-stdio reliability first. If unavailable, prefer socket mode // (Supervisor already falls back socket -> tcp -> stdio at runtime). // (Override via ROBOPPI_SUPERVISED_IPC_TRANSPORT=stdio|socket|tcp.) if (supervised && process.env.ROBOPPI_SUPERVISED_IPC_TRANSPORT === undefined) { - const supportsStdio = await supportsChildBunStdinPipe(); + const supportsStdio = await supportsChildBunStdinPipe(supervisedCoreEntryPoint); process.env.ROBOPPI_SUPERVISED_IPC_TRANSPORT = supportsStdio ? "stdio" : "socket"; if (!supportsStdio) { process.stderr.write( @@ -385,7 +389,7 @@ Options: const runner = supervised ? new CoreIpcStepRunner({ verbose, - coreEntryPoint: resolveCoreEntryPointForSupervised(coreEntryPointOverride), + coreEntryPoint: supervisedCoreEntryPoint, ipcRequestTimeoutMs: resolveIpcRequestTimeoutMs(true, ipcRequestTimeout), captureCoreStderr: useTui && sink instanceof TuiStateStore, sink, diff --git a/src/workflow/supervised-ipc-capability.ts b/src/workflow/supervised-ipc-capability.ts index 78bf7c7..b8c495e 100644 --- a/src/workflow/supervised-ipc-capability.ts +++ b/src/workflow/supervised-ipc-capability.ts @@ -1,21 +1,77 @@ import path from "node:path"; -let cachedProbe: Promise | null = null; +const cachedProbes = new Map>(); + +const BUN_SCRIPT_EXTENSIONS = new Set([".ts", ".js", ".tsx", ".jsx"]); function isBunExecutable(executablePath: string): boolean { const base = path.basename(executablePath).toLowerCase(); return base === "bun" || base === "bun.exe"; } -export async function supportsChildBunStdinPipe(): Promise { - if (!isBunExecutable(process.execPath)) { +type RuntimeVersions = NodeJS.ProcessVersions & { bun?: string }; + +function isBunScriptEntryPoint(childEntryPoint: string | undefined): boolean { + if (!childEntryPoint) return false; + return BUN_SCRIPT_EXTENSIONS.has(path.extname(childEntryPoint).toLowerCase()); +} + +export function isBunRuntime( + executablePath: string = process.execPath, + versions: RuntimeVersions = process.versions, +): boolean { + return typeof versions.bun === "string" && versions.bun.trim() !== "" + ? true + : isBunExecutable(executablePath); +} + +export function usesBunChildRuntime( + childEntryPoint: string | undefined, + executablePath: string = process.execPath, + versions: RuntimeVersions = process.versions, +): boolean { + return isBunScriptEntryPoint(childEntryPoint) || isBunRuntime(executablePath, versions); +} + +function resolveProbeCommand( + childEntryPoint: string | undefined, + executablePath: string = process.execPath, + versions: RuntimeVersions = process.versions, +): string[] | null { + if (isBunScriptEntryPoint(childEntryPoint)) { + return ["bun", "-e"]; + } + if (isBunExecutable(executablePath)) { + return [executablePath, "-e"]; + } + if (typeof versions.bun === "string" && versions.bun.trim() !== "") { + // Bun-compiled binaries spawn as executables, but they do not support `-e`. + // Prefer the safer socket transport instead of probing with a mismatched command. + return null; + } + return null; +} + +export async function supportsChildBunStdinPipe( + childEntryPoint?: string, + executablePath: string = process.execPath, + versions: RuntimeVersions = process.versions, +): Promise { + if (!usesBunChildRuntime(childEntryPoint, executablePath, versions)) { // The known stdin pipe issue is Bun-runtime specific. return true; } - if (cachedProbe) return cachedProbe; + const probeCommand = resolveProbeCommand(childEntryPoint, executablePath, versions); + if (!probeCommand) { + return false; + } + + const cacheKey = probeCommand.join("\0"); + const cached = cachedProbes.get(cacheKey); + if (cached) return cached; - cachedProbe = new Promise((resolve) => { + const probe = new Promise((resolve) => { const token = `probe-${Date.now()}-${Math.random().toString(16).slice(2)}`; const script = "process.stdin.setEncoding('utf8');" + @@ -33,7 +89,7 @@ export async function supportsChildBunStdinPipe(): Promise { } const child = bunAny.spawn({ - cmd: [process.execPath, "-e", script], + cmd: [...probeCommand, script], stdin: "pipe", stdout: "pipe", stderr: "ignore", @@ -91,5 +147,6 @@ export async function supportsChildBunStdinPipe(): Promise { setTimeout(() => finish(stdout.includes(token)), 2000); }); - return cachedProbe; + cachedProbes.set(cacheKey, probe); + return probe; } diff --git a/src/workflow/types.ts b/src/workflow/types.ts index e6badf2..769d011 100644 --- a/src/workflow/types.ts +++ b/src/workflow/types.ts @@ -85,12 +85,17 @@ export interface StallPolicy { export interface AgentMemberConfig { agent: string; + role?: string; } export interface AgentSeedTaskConfig { + id?: string; title: string; description: string; assigned_to?: string; + depends_on?: string[]; + tags?: string[]; + requires_plan_approval?: boolean; } export interface AgentsWorkflowConfig { @@ -140,6 +145,8 @@ export interface StepDefinition { /** Optional agent profile id (resolved from an agent catalog). */ agent?: string; worker?: "CODEX_CLI" | "CLAUDE_CODE" | "OPENCODE" | "CUSTOM"; + /** Optional CLI args forwarded to the resolved worker adapter. */ + defaultArgs?: string[]; /** Optional model identifier for LLM-backed workers (adapter-specific format). */ model?: string; /** Optional model variant / reasoning-effort hint (worker-specific). */ @@ -254,6 +261,8 @@ export interface CompletionCheckDef { /** Optional agent profile id (resolved from an agent catalog). */ agent?: string; worker: "CODEX_CLI" | "CLAUDE_CODE" | "OPENCODE" | "CUSTOM"; + /** Optional CLI args forwarded to the resolved worker adapter. */ + defaultArgs?: string[]; /** Optional model identifier for LLM-backed workers (adapter-specific format). */ model?: string; /** Optional model variant / reasoning-effort hint (worker-specific). */ diff --git a/test/integration/core-ipc-step-runner.test.ts b/test/integration/core-ipc-step-runner.test.ts index 37cffa9..84c1ae3 100644 --- a/test/integration/core-ipc-step-runner.test.ts +++ b/test/integration/core-ipc-step-runner.test.ts @@ -8,10 +8,20 @@ import { IpcProtocol } from "../../src/ipc/protocol.js"; import { AgentCore } from "../../src/core/agentcore.js"; import { MockWorkerAdapter } from "../../src/worker/adapters/mock-adapter.js"; import { WorkerKind } from "../../src/types/index.js"; +import type { WorkerTask } from "../../src/types/index.js"; import type { StepDefinition } from "../../src/workflow/types.js"; import { CoreIpcStepRunner } from "../../src/workflow/core-ipc-step-runner.js"; import { createIpcStreamPair } from "../helpers/fixtures.js"; +class CaptureWorkerAdapter extends MockWorkerAdapter { + lastTask: WorkerTask | null = null; + + override async startTask(task: WorkerTask) { + this.lastTask = task; + return super.startTask(task); + } +} + describe("CoreIpcStepRunner (in-process IPC)", () => { let core: AgentCore | null = null; let schedulerProtocol: IpcProtocol | null = null; @@ -109,4 +119,38 @@ describe("CoreIpcStepRunner (in-process IPC)", () => { // No ghost workers remain. expect(core!.getWorkerGateway().getActiveWorkerCount()).toBe(0); }); + + test("preserves defaultArgs and model across Core IPC", async () => { + await setup({ delayMs: 10 }); + + const captureAdapter = new CaptureWorkerAdapter(WorkerKind.CODEX_CLI, { delayMs: 10 }); + core!.getWorkerGateway().registerAdapter(WorkerKind.CODEX_CLI, captureAdapter); + + const step: StepDefinition = { + worker: "CODEX_CLI", + instructions: "Run manual cluster verification", + capabilities: ["READ"], + timeout: "10s", + defaultArgs: ["--sandbox", "danger-full-access", "--ask-for-approval", "never"], + model: "gpt-5.4", + }; + + const result = await runner!.runStep( + "step-default-args", + step, + workspaceDir!, + new AbortController().signal, + ); + + expect(result.status).toBe("SUCCEEDED"); + expect(captureAdapter.lastTask).not.toBeNull(); + expect(captureAdapter.lastTask!.workerKind).toBe(WorkerKind.CODEX_CLI); + expect(captureAdapter.lastTask!.defaultArgs).toEqual([ + "--sandbox", + "danger-full-access", + "--ask-for-approval", + "never", + ]); + expect(captureAdapter.lastTask!.model).toBe("gpt-5.4"); + }); }); diff --git a/test/integration/management-executor.test.ts b/test/integration/management-executor.test.ts index 96d7029..3239e7a 100644 --- a/test/integration/management-executor.test.ts +++ b/test/integration/management-executor.test.ts @@ -2130,6 +2130,7 @@ version: "1" agents: mgr: worker: CLAUDE_CODE + defaultArgs: [--verbose, --permission-mode, bypassPermissions] model: openai/gpt-5.2 capabilities: [READ] base_instructions: | @@ -2195,6 +2196,11 @@ steps: expect(state.status).toBe(WorkflowStatus.SUCCEEDED); expect(capturedMgmtStepDef).not.toBeNull(); expect(capturedMgmtStepDef!.worker).toBe("CLAUDE_CODE"); + expect(capturedMgmtStepDef!.defaultArgs).toEqual([ + "--verbose", + "--permission-mode", + "bypassPermissions", + ]); expect(capturedMgmtStepDef!.model).toBe("openai/gpt-5.2"); expect(capturedMgmtStepDef!.capabilities).toEqual(["READ"]); expect(capturedMgmtStepDef!.instructions).toContain("CATALOG BASE"); diff --git a/test/unit/agents/resident-agent.test.ts b/test/unit/agents/resident-agent.test.ts new file mode 100644 index 0000000..47630cf --- /dev/null +++ b/test/unit/agents/resident-agent.test.ts @@ -0,0 +1,64 @@ +import { describe, test, expect } from "bun:test"; + +import { createAdapterForAgentProfile } from "../../../src/agents/resident-agent.js"; +import { CodexCliAdapter } from "../../../src/worker/adapters/codex-cli-adapter.js"; +import type { ProcessManager } from "../../../src/worker/process-manager.js"; +import { + WorkerKind, + WorkerCapability, + OutputMode, + generateId, +} from "../../../src/types/index.js"; +import type { WorkerTask } from "../../../src/types/index.js"; + +function createTask(overrides: Partial = {}): WorkerTask { + return { + workerTaskId: generateId(), + workerKind: WorkerKind.CODEX_CLI, + workspaceRef: "/tmp/test-workspace", + instructions: "Run live cluster verification", + capabilities: [ + WorkerCapability.READ, + WorkerCapability.EDIT, + WorkerCapability.RUN_COMMANDS, + ], + outputMode: OutputMode.STREAM, + budget: { + deadlineAt: Date.now() + 60000, + }, + abortSignal: new AbortController().signal, + ...overrides, + }; +} + +describe("createAdapterForAgentProfile", () => { + test("preserves profile defaultArgs for resident CODEX_CLI agents", () => { + const adapter = createAdapterForAgentProfile( + {} as ProcessManager, + { + worker: "CODEX_CLI", + defaultArgs: [ + "--dangerously-bypass-approvals-and-sandbox", + ], + }, + ); + + expect(adapter).toBeInstanceOf(CodexCliAdapter); + + const command = (adapter as CodexCliAdapter).buildCommand( + createTask({ model: "openai/gpt-5.4" }), + ); + + expect(command).toEqual([ + "codex", + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "--model", + "gpt-5.4", + "--json", + "--cd", + "/tmp/test-workspace", + "Run live cluster verification", + ]); + }); +}); diff --git a/test/unit/agents/task-claiming.test.ts b/test/unit/agents/task-claiming.test.ts new file mode 100644 index 0000000..8f62888 --- /dev/null +++ b/test/unit/agents/task-claiming.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { ResidentAgent } from "../../../src/agents/resident-agent.js"; +import { addTask, claimTask, completeTask, listTasks } from "../../../src/agents/task-store.js"; +import type { ExecEventSink } from "../../../src/tui/exec-event.js"; +import type { AgentProfile } from "../../../src/workflow/agent-catalog.js"; + +const sink: ExecEventSink = { + emit() { + // no-op for unit tests + }, +}; + +const profile: AgentProfile = { + worker: "CLAUDE_CODE", +}; + +function createResidentAgent(contextDir: string, memberId: string): ResidentAgent { + return new ResidentAgent({ + contextDir, + memberId, + teamId: "team-1", + profile, + workspaceDir: contextDir, + sink, + env: {}, + }); +} + +function hasClaimableTasks(agent: ResidentAgent): Promise { + return (agent as unknown as { hasClaimableTasks(): Promise }).hasClaimableTasks(); +} + +describe("task claiming respects assigned_to", () => { + let contextDir: string; + + beforeEach(async () => { + contextDir = await mkdtemp(path.join(tmpdir(), "roboppi-task-claim-")); + }); + + afterEach(async () => { + await rm(contextDir, { recursive: true, force: true }); + }); + + test("lead cannot claim a task assigned to manual_verifier and it stays pending", async () => { + const { taskId } = await addTask({ + contextDir, + title: "Verify live cluster blocker", + description: "Preserve the cluster and verify the blocker fix.", + assignedTo: "manual_verifier", + }); + + const denied = await claimTask(contextDir, taskId, "lead"); + + expect(denied.ok).toBe(false); + expect(denied.error).toContain("manual_verifier"); + + const pending = await listTasks(contextDir, "pending"); + const inProgress = await listTasks(contextDir, "in_progress"); + + expect(pending).toHaveLength(1); + expect(pending[0]?.task_id).toBe(taskId); + expect(pending[0]?.claimed_by).toBeNull(); + expect(inProgress).toHaveLength(0); + }); + + test("the assigned member can claim the task after a denied claim", async () => { + const { taskId } = await addTask({ + contextDir, + title: "Verify live cluster blocker", + description: "Preserve the cluster and verify the blocker fix.", + assignedTo: "manual_verifier", + }); + + const denied = await claimTask(contextDir, taskId, "lead"); + expect(denied.ok).toBe(false); + + const claimed = await claimTask(contextDir, taskId, "manual_verifier"); + + expect(claimed).toEqual({ ok: true }); + + const pending = await listTasks(contextDir, "pending"); + const inProgress = await listTasks(contextDir, "in_progress"); + + expect(pending).toHaveLength(0); + expect(inProgress).toHaveLength(1); + expect(inProgress[0]?.task_id).toBe(taskId); + expect(inProgress[0]?.claimed_by).toBe("manual_verifier"); + expect(inProgress[0]?.assigned_to).toBe("manual_verifier"); + }); + + test("unassigned tasks remain claimable", async () => { + const { taskId } = await addTask({ + contextDir, + title: "Handle unassigned triage", + description: "Any member may pick this up.", + }); + + const claimed = await claimTask(contextDir, taskId, "lead"); + + expect(claimed).toEqual({ ok: true }); + + const inProgress = await listTasks(contextDir, "in_progress"); + expect(inProgress).toHaveLength(1); + expect(inProgress[0]?.task_id).toBe(taskId); + expect(inProgress[0]?.claimed_by).toBe("lead"); + expect(inProgress[0]?.assigned_to).toBeNull(); + }); + + test("resident agents use the same claimability rule", async () => { + await addTask({ + contextDir, + title: "Verify live cluster blocker", + description: "Preserve the cluster and verify the blocker fix.", + assignedTo: "manual_verifier", + }); + + const leadAgent = createResidentAgent(contextDir, "lead"); + const manualVerifierAgent = createResidentAgent(contextDir, "manual_verifier"); + + expect(await hasClaimableTasks(leadAgent)).toBe(false); + expect(await hasClaimableTasks(manualVerifierAgent)).toBe(true); + }); + + test("resident agents ignore pending tasks whose dependencies are not yet complete", async () => { + const { taskId: bootstrapId } = await addTask({ + contextDir, + title: "Bootstrap evidence", + description: "Create the prerequisite evidence first.", + assignedTo: "lead", + }); + + await addTask({ + contextDir, + title: "Verify live cluster blocker", + description: "Preserve the cluster and verify the blocker fix.", + assignedTo: "manual_verifier", + dependsOn: [bootstrapId], + }); + + const manualVerifierAgent = createResidentAgent(contextDir, "manual_verifier"); + expect(await hasClaimableTasks(manualVerifierAgent)).toBe(false); + + expect(await claimTask(contextDir, bootstrapId, "lead")).toEqual({ ok: true }); + expect(await completeTask(contextDir, bootstrapId, "lead")).toEqual({ ok: true }); + + expect(await hasClaimableTasks(manualVerifierAgent)).toBe(true); + }); +}); diff --git a/test/unit/agents/task-store.test.ts b/test/unit/agents/task-store.test.ts new file mode 100644 index 0000000..9ca276f --- /dev/null +++ b/test/unit/agents/task-store.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { addTask, claimTask, completeTask, listTasks } from "../../../src/agents/task-store.js"; + +const tmpDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tmpDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function makeContextDir(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "roboppi-task-store-")); + tmpDirs.push(dir); + return dir; +} + +describe("task-store claim ownership", () => { + test("denies non-assignee claims and preserves the task for the assignee", async () => { + const contextDir = await makeContextDir(); + const { taskId } = await addTask({ + contextDir, + title: "manual verification", + description: "run manual verification", + assignedTo: "manual_verifier", + }); + + const denied = await claimTask(contextDir, taskId, "lead"); + expect(denied.ok).toBe(false); + expect(denied.error).toContain('assigned to "manual_verifier"'); + + const pending = await listTasks(contextDir, "pending"); + expect(pending).toHaveLength(1); + expect(pending[0]?.task_id).toBe(taskId); + expect(pending[0]?.claimed_by).toBeNull(); + + const claimed = await claimTask(contextDir, taskId, "manual_verifier"); + expect(claimed.ok).toBe(true); + + const inProgress = await listTasks(contextDir, "in_progress"); + expect(inProgress).toHaveLength(1); + expect(inProgress[0]?.task_id).toBe(taskId); + expect(inProgress[0]?.claimed_by).toBe("manual_verifier"); + }); + + test("allows unassigned tasks to be claimed by any member", async () => { + const contextDir = await makeContextDir(); + const { taskId } = await addTask({ + contextDir, + title: "general work", + description: "do shared work", + }); + + const claimed = await claimTask(contextDir, taskId, "lead"); + expect(claimed.ok).toBe(true); + + const inProgress = await listTasks(contextDir, "in_progress"); + expect(inProgress).toHaveLength(1); + expect(inProgress[0]?.task_id).toBe(taskId); + expect(inProgress[0]?.claimed_by).toBe("lead"); + }); + + test("denies completion by a member that did not claim the task", async () => { + const contextDir = await makeContextDir(); + const { taskId } = await addTask({ + contextDir, + title: "manual verification", + description: "run manual verification", + assignedTo: "manual_verifier", + }); + + expect((await claimTask(contextDir, taskId, "manual_verifier")).ok).toBe(true); + + const denied = await completeTask(contextDir, taskId, "lead"); + expect(denied.ok).toBe(false); + expect(denied.error).toContain('claimed by "manual_verifier"'); + + const inProgress = await listTasks(contextDir, "in_progress"); + expect(inProgress).toHaveLength(1); + expect(inProgress[0]?.task_id).toBe(taskId); + expect(inProgress[0]?.claimed_by).toBe("manual_verifier"); + }); +}); diff --git a/test/unit/tui/state-store.test.ts b/test/unit/tui/state-store.test.ts index 470229d..45ff5b8 100644 --- a/test/unit/tui/state-store.test.ts +++ b/test/unit/tui/state-store.test.ts @@ -9,6 +9,7 @@ describe("TuiStateStore", () => { const store = new TuiStateStore(); expect(store.state.steps.size).toBe(0); expect(store.state.stepOrder).toEqual([]); + expect(store.state.agentRosterOrder).toEqual([]); expect(store.state.followMode).toBe("running"); expect(store.state.selectedTab).toBe("overview"); expect(store.state.coreLogs.length).toBe(0); @@ -115,6 +116,61 @@ describe("TuiStateStore", () => { expect(step.phase).toBe("executing"); }); + test("tracks current and last agent instructions from executing phases", () => { + const store = new TuiStateStore(); + store.emit({ + type: "step_phase", + stepId: "_agent:planner", + phase: "executing", + at: 1000, + detail: { + instructions: "You are planner.\nCheck tasks.", + }, + }); + + let stats = store.state.agentRuntime.get("planner"); + expect(stats?.dispatchCount).toBe(1); + expect(stats?.currentInstructions).toBe("You are planner.\nCheck tasks."); + expect(stats?.lastInstructions).toBe("You are planner.\nCheck tasks."); + + store.emit({ + type: "step_phase", + stepId: "_agent:planner", + phase: "ready", + at: 1500, + }); + + stats = store.state.agentRuntime.get("planner"); + expect(stats?.currentInstructions).toBeUndefined(); + expect(stats?.lastInstructions).toBe("You are planner.\nCheck tasks."); + }); + + test("syncs workflow status summary and marks store dirty only when changed", () => { + const store = new TuiStateStore(); + store.syncWorkflowStatusSummary({ + version: "1", + updated_at: 1000, + owner_member_id: "planner", + summary: "Implementer is working on the top blocker.", + blockers: ["kind bootstrap is failing"], + next_actions: ["wait for patch"], + }); + + expect(store.state.workflowStatusSummary?.owner_member_id).toBe("planner"); + expect(store.dirty).toBe(true); + + store.dirty = false; + store.syncWorkflowStatusSummary({ + version: "1", + updated_at: 1000, + owner_member_id: "planner", + summary: "Implementer is working on the top blocker.", + blockers: ["kind bootstrap is failing"], + next_actions: ["wait for patch"], + }); + expect(store.dirty).toBe(false); + }); + test("handles worker_event stdout", () => { const store = new TuiStateStore(); store.emit({ @@ -298,6 +354,145 @@ describe("TuiStateStore", () => { expect(store.state.supervised).toBe(true); }); + test("syncs agent roster and marks the store dirty only when it changes", () => { + const store = new TuiStateStore(); + + store.syncAgentRoster([ + { memberId: "manual_verifier", name: "manual_verifier", role: "dormant", agentId: "manual_verifier" }, + { memberId: "reviewer", name: "reviewer", role: "member", agentId: "reviewer" }, + ]); + + expect(store.dirty).toBe(true); + expect(store.state.agentRosterOrder).toEqual(["manual_verifier", "reviewer"]); + expect(store.state.agentRoster.get("manual_verifier")?.role).toBe("dormant"); + + store.dirty = false; + store.syncAgentRoster([ + { memberId: "manual_verifier", name: "manual_verifier", role: "dormant", agentId: "manual_verifier" }, + { memberId: "reviewer", name: "reviewer", role: "member", agentId: "reviewer" }, + ]); + expect(store.dirty).toBe(false); + + store.syncAgentRoster([ + { memberId: "manual_verifier", name: "manual_verifier", role: "member", agentId: "manual_verifier" }, + { memberId: "reviewer", name: "reviewer", role: "member", agentId: "reviewer" }, + ]); + expect(store.dirty).toBe(true); + expect(store.state.agentRoster.get("manual_verifier")?.role).toBe("member"); + }); + + test("tracks agent dispatch counts, active time, and restarts", () => { + const store = new TuiStateStore(); + + store.emit({ + type: "step_state", + stepId: "_agent:reviewer", + status: StepStatus.RUNNING, + iteration: 1, + maxIterations: 1, + startedAt: 1000, + }); + store.emit({ + type: "step_phase", + stepId: "_agent:reviewer", + phase: "executing", + at: 1200, + }); + store.emit({ + type: "step_phase", + stepId: "_agent:reviewer", + phase: "ready", + at: 1700, + }); + store.emit({ + type: "step_state", + stepId: "_agent:reviewer", + status: StepStatus.SUCCEEDED, + iteration: 1, + maxIterations: 1, + completedAt: 2000, + }); + store.emit({ + type: "step_state", + stepId: "_agent:reviewer", + status: StepStatus.RUNNING, + iteration: 2, + maxIterations: 2, + startedAt: 3000, + }); + store.emit({ + type: "step_phase", + stepId: "_agent:reviewer", + phase: "executing", + at: 3100, + }); + store.emit({ + type: "step_state", + stepId: "_agent:reviewer", + status: StepStatus.FAILED, + iteration: 2, + maxIterations: 2, + completedAt: 3600, + error: "timeout", + }); + + const stats = store.state.agentRuntime.get("reviewer"); + expect(stats).toBeDefined(); + expect(stats?.dispatchCount).toBe(2); + expect(stats?.restartCount).toBe(1); + expect(stats?.lastStartedAt).toBe(3000); + expect(stats?.lastStoppedAt).toBe(3600); + expect(stats?.lastDispatchStartedAt).toBe(3100); + expect(stats?.lastDispatchFinishedAt).toBe(3600); + expect(stats?.lastDispatchDurationMs).toBe(500); + expect(stats?.totalDispatchActiveMs).toBe(1000); + expect(stats?.currentlyDispatchingSince).toBeUndefined(); + }); + + test("tracks token estimates and prompt bytes from agent worker results", () => { + const store = new TuiStateStore(); + + store.emit({ + type: "worker_result", + stepId: "_agent:planner", + ts: 1000, + result: { + status: WorkerStatus.SUCCEEDED, + artifacts: [], + observations: [], + cost: { + wallTimeMs: 500, + estimatedTokens: 1200, + instructionBytes: 4096, + }, + durationMs: 500, + }, + }); + store.emit({ + type: "worker_result", + stepId: "_agent:planner", + ts: 2000, + result: { + status: WorkerStatus.SUCCEEDED, + artifacts: [], + observations: [], + cost: { + wallTimeMs: 300, + instructionBytes: 2048, + }, + durationMs: 300, + }, + }); + + const stats = store.state.agentRuntime.get("planner"); + expect(stats).toBeDefined(); + expect(stats?.tokenSampleCount).toBe(1); + expect(stats?.totalEstimatedTokens).toBe(1200); + expect(stats?.lastEstimatedTokens).toBe(1200); + expect(stats?.totalInstructionBytes).toBe(6144); + expect(stats?.lastInstructionBytes).toBe(2048); + }); + // ------------------------------------------------------------------------- // Agents events // ------------------------------------------------------------------------- diff --git a/test/unit/tui/step-list.test.ts b/test/unit/tui/step-list.test.ts new file mode 100644 index 0000000..a2adb8d --- /dev/null +++ b/test/unit/tui/step-list.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { TuiStateStore } from "../../../src/tui/state-store.js"; +import { buildLeftPaneEntries, renderStepList } from "../../../src/tui/components/step-list.js"; + +describe("step-list", () => { + test("includes dormant roster agents in the left pane", () => { + const store = new TuiStateStore(); + store.emit({ + type: "workflow_started", + workflowId: "wf-1", + name: "test", + workspaceDir: "/tmp/ws", + supervised: true, + startedAt: 1000, + definitionSummary: { + steps: ["orchestrate"], + timeout: "30m", + }, + }); + store.syncAgentRoster([ + { memberId: "manual_verifier", name: "manual_verifier", role: "dormant", agentId: "manual_verifier" }, + { memberId: "reviewer", name: "reviewer", role: "member", agentId: "reviewer" }, + ]); + + const entries = buildLeftPaneEntries(store.state); + expect(entries).toEqual([ + { kind: "step", stepId: "orchestrate" }, + { kind: "separator", label: "Agents" }, + { kind: "agent", stepId: "_agent:manual_verifier", memberId: "manual_verifier" }, + { kind: "agent", stepId: "_agent:reviewer", memberId: "reviewer" }, + ]); + + const rendered = renderStepList(store.state, 80, 10); + expect(rendered).toContain("manual_verifier"); + expect(rendered).toContain("sleeping"); + }); +}); diff --git a/test/unit/types/worker-task-job-payload.test.ts b/test/unit/types/worker-task-job-payload.test.ts index 373d4dc..0693e41 100644 --- a/test/unit/types/worker-task-job-payload.test.ts +++ b/test/unit/types/worker-task-job-payload.test.ts @@ -25,6 +25,7 @@ describe("isWorkerTaskJobPayload", () => { it("returns true when all optional fields are present", () => { const p = { ...validPayload(), + defaultArgs: ["--sandbox", "danger-full-access"], model: "claude-sonnet-4-6", env: { FOO: "bar", BAZ: "qux" }, budget: { @@ -132,4 +133,8 @@ describe("isWorkerTaskJobPayload", () => { it("returns false when env is null", () => { expect(isWorkerTaskJobPayload({ ...validPayload(), env: null })).toBe(false); }); + + it("returns false when defaultArgs contain a non-string", () => { + expect(isWorkerTaskJobPayload({ ...validPayload(), defaultArgs: ["--sandbox", 42] })).toBe(false); + }); }); diff --git a/test/unit/worker/claude-code-adapter.test.ts b/test/unit/worker/claude-code-adapter.test.ts index 8c7144f..c924b65 100644 --- a/test/unit/worker/claude-code-adapter.test.ts +++ b/test/unit/worker/claude-code-adapter.test.ts @@ -203,6 +203,21 @@ describe("buildArgs", () => { expect(args).toContain("--no-telemetry"); }); + test("includes task defaultArgs after config defaultArgs", () => { + const task = makeTask({ defaultArgs: ["--permission-mode", "bypassPermissions"] }); + const config = { + ...makeDefaultConfig(), + defaultArgs: ["--verbose"], + }; + const args = buildArgs(task, config); + + expect(args.slice(0, 3)).toEqual([ + "--verbose", + "--permission-mode", + "bypassPermissions", + ]); + }); + test("includes --model when task.model is set (overrides defaultArgs)", () => { const task = makeTask({ model: "claude-opus-4-6" }); const config = { diff --git a/test/unit/worker/codex-cli-adapter.test.ts b/test/unit/worker/codex-cli-adapter.test.ts index 36ae097..a2c0340 100644 --- a/test/unit/worker/codex-cli-adapter.test.ts +++ b/test/unit/worker/codex-cli-adapter.test.ts @@ -152,6 +152,42 @@ describe("CodexCliAdapter", () => { ]); }); + test("should include task default args from workflow-resolved profiles", () => { + const task = createTask({ + capabilities: [WorkerCapability.READ], + defaultArgs: ["--sandbox", "danger-full-access", "--ask-for-approval", "never"], + }); + const command = adapter.buildCommand(task); + expect(command).toEqual([ + "codex", + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "--json", + "--cd", + "/tmp/test-workspace", + "Fix the bug in main.ts", + ]); + }); + + test("should not append full-auto or sandbox when bypass mode is already requested", () => { + const task = createTask({ + defaultArgs: ["--dangerously-bypass-approvals-and-sandbox"], + }); + const command = adapter.buildCommand(task); + + expect(command).toEqual([ + "codex", + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "--json", + "--cd", + "/tmp/test-workspace", + "Fix the bug in main.ts", + ]); + expect(command).not.toContain("--full-auto"); + expect(command).not.toContain("--sandbox"); + }); + test("should include --model from task and normalize provider prefix", () => { const customAdapter = new CodexCliAdapter(mockPm.pm, { defaultArgs: ["--model", "gpt-4.1-codex"], diff --git a/test/unit/worker/opencode-adapter.test.ts b/test/unit/worker/opencode-adapter.test.ts index c576077..b6f97d3 100644 --- a/test/unit/worker/opencode-adapter.test.ts +++ b/test/unit/worker/opencode-adapter.test.ts @@ -127,6 +127,21 @@ describe("OpenCodeAdapter", () => { expect(args[3]).toBe("--verbose"); }); + test("includes task defaultArgs after config defaultArgs", () => { + const config = { ...defaultConfig, defaultArgs: ["--verbose"] }; + const task = makeTask({ defaultArgs: ["--sandbox", "danger-full-access"] }); + const args = buildArgs(task, config); + + expect(args.slice(0, 6)).toEqual([ + "run", + "--format", + "json", + "--verbose", + "--sandbox", + "danger-full-access", + ]); + }); + test("places instructions as the last argument", () => { const task = makeTask({ instructions: "Do something special" }); const args = buildArgs(task, defaultConfig); diff --git a/test/unit/workflow/agent-catalog.test.ts b/test/unit/workflow/agent-catalog.test.ts index 74680e7..2ad3970 100644 --- a/test/unit/workflow/agent-catalog.test.ts +++ b/test/unit/workflow/agent-catalog.test.ts @@ -22,6 +22,27 @@ agents: expect(catalog.research!.base_instructions).toBe("You are a research agent"); }); + it("parses resident worker defaultArgs", () => { + const yaml = ` +version: "1" +agents: + verifier: + worker: CODEX_CLI + defaultArgs: + - --full-auto + - --sandbox + - danger-full-access +`; + + const catalog = parseAgentCatalog(yaml); + expect(catalog.verifier).toBeDefined(); + expect(catalog.verifier!.defaultArgs).toEqual([ + "--full-auto", + "--sandbox", + "danger-full-access", + ]); + }); + it("rejects wrong version", () => { const yaml = ` version: "2" diff --git a/test/unit/workflow/executor.test.ts b/test/unit/workflow/executor.test.ts index f8df4ee..f8e3005 100644 --- a/test/unit/workflow/executor.test.ts +++ b/test/unit/workflow/executor.test.ts @@ -1532,6 +1532,8 @@ describe("WorkflowExecutor", () => { const memberIds = membersJson.members.map((m: { member_id: string }) => m.member_id); expect(memberIds).toContain("lead"); expect(memberIds).toContain("researcher"); + const researcher = membersJson.members.find((m: { member_id: string }) => m.member_id === "researcher"); + expect(researcher.role).toBe("member"); // Verify tasks were seeded (check pending dir) const { readdir: rd } = await import("node:fs/promises"); @@ -1542,6 +1544,106 @@ describe("WorkflowExecutor", () => { }); }); + it("translates agent seed task dependencies into concrete task ids", async () => { + await withTempDir(async (dir) => { + const runner = new MockStepRunner(); + const wf = makeWorkflow( + { s1: makeStep() }, + { + agents: { + enabled: true, + team_name: "deps-team", + members: { + lead: { agent: "lead-agent" }, + reviewer: { agent: "review-agent" }, + }, + tasks: [ + { id: "bootstrap", title: "Bootstrap", description: "Desc 1", assigned_to: "lead" }, + { + id: "review", + title: "Review", + description: "Desc 2", + assigned_to: "reviewer", + depends_on: ["bootstrap"], + tags: ["phase:review"], + requires_plan_approval: true, + }, + ], + }, + }, + ); + + const ctxDir = path.join(dir, "context"); + const ctx = new ContextManager(ctxDir); + const executor = new WorkflowExecutor(wf, ctx, runner, dir); + const result = await executor.execute(); + + expect(result.status).toBe(WorkflowStatus.SUCCEEDED); + + const { readdir: rd } = await import("node:fs/promises"); + const pendingDir = path.join(ctxDir, "_agents", "tasks", "pending"); + const pendingFiles = (await rd(pendingDir)).filter((f: string) => f.endsWith(".json")); + expect(pendingFiles).toHaveLength(2); + + const tasks = await Promise.all( + pendingFiles.map(async (filename) => + JSON.parse(await readFile(path.join(pendingDir, filename), "utf-8")) as { + task_id: string; + title: string; + depends_on: string[]; + tags: string[]; + requires_plan_approval: boolean; + }, + ), + ); + + const bootstrap = tasks.find((task) => task.title === "Bootstrap"); + const review = tasks.find((task) => task.title === "Review"); + expect(bootstrap).toBeDefined(); + expect(review).toBeDefined(); + expect(review?.depends_on).toEqual([bootstrap!.task_id]); + expect(review?.tags).toEqual(["phase:review"]); + expect(review?.requires_plan_approval).toBe(true); + }); + }); + + it("persists dormant member roles without making them the lead", async () => { + await withTempDir(async (dir) => { + const runner = new MockStepRunner(); + const wf = makeWorkflow( + { s1: makeStep() }, + { + agents: { + enabled: true, + team_name: "roles-team", + members: { + lead: { agent: "lead-agent" }, + reviewer: { agent: "review-agent", role: "dormant" }, + }, + }, + }, + ); + + const ctxDir = path.join(dir, "context"); + const ctx = new ContextManager(ctxDir); + const executor = new WorkflowExecutor(wf, ctx, runner, dir); + const result = await executor.execute(); + + expect(result.status).toBe(WorkflowStatus.SUCCEEDED); + + const membersJson = JSON.parse( + await readFile(path.join(ctxDir, "_agents", "members.json"), "utf-8"), + ); + const reviewer = membersJson.members.find((m: { member_id: string }) => m.member_id === "reviewer"); + expect(reviewer.role).toBe("dormant"); + + const teamJson = JSON.parse( + await readFile(path.join(ctxDir, "_agents", "team.json"), "utf-8"), + ); + expect(teamJson.lead_member_id).toBe("lead"); + }); + }); + it("passes agents env vars to step runner", async () => { await withTempDir(async (dir) => { let capturedEnv: Record | undefined; diff --git a/test/unit/workflow/parser.test.ts b/test/unit/workflow/parser.test.ts index 0a6e2e2..a8e92f7 100644 --- a/test/unit/workflow/parser.test.ts +++ b/test/unit/workflow/parser.test.ts @@ -188,6 +188,7 @@ version: "1" agents: research: worker: OPENCODE + defaultArgs: [--sandbox, danger-full-access] model: openai/gpt-5.2 capabilities: [READ] base_instructions: | @@ -206,6 +207,7 @@ steps: const wf = parseWorkflow(yaml, { agents }); expect(wf.steps["s"]!.worker).toBe("OPENCODE"); + expect(wf.steps["s"]!.defaultArgs).toEqual(["--sandbox", "danger-full-access"]); expect(wf.steps["s"]!.model).toBe("openai/gpt-5.2"); expect(wf.steps["s"]!.capabilities).toEqual(["READ"]); expect(wf.steps["s"]!.instructions).toBe("You are a research agent.\n\nFind relevant docs"); @@ -216,7 +218,9 @@ steps: version: "1" agents: checker: - worker: CUSTOM + worker: CODEX_CLI + defaultArgs: [--sandbox, danger-full-access] + model: gpt-5.4 capabilities: [READ] `); @@ -237,7 +241,12 @@ steps: `; const wf = parseWorkflow(yaml, { agents }); - expect(wf.steps["s"]!.completion_check!.worker).toBe("CUSTOM"); + expect(wf.steps["s"]!.completion_check!.worker).toBe("CODEX_CLI"); + expect(wf.steps["s"]!.completion_check!.defaultArgs).toEqual([ + "--sandbox", + "danger-full-access", + ]); + expect(wf.steps["s"]!.completion_check!.model).toBe("gpt-5.4"); expect(wf.steps["s"]!.completion_check!.capabilities).toEqual(["READ"]); }); }); @@ -724,6 +733,7 @@ agents: agent: lead-agent researcher: agent: research-agent + role: dormant tasks: - title: "Investigate" description: "Look at the code" @@ -741,6 +751,7 @@ steps: expect(wf.agents!.members).toBeDefined(); expect(wf.agents!.members!["lead"]!.agent).toBe("lead-agent"); expect(wf.agents!.members!["researcher"]!.agent).toBe("research-agent"); + expect(wf.agents!.members!["researcher"]!.role).toBe("dormant"); expect(wf.agents!.tasks).toHaveLength(1); expect(wf.agents!.tasks![0]!.title).toBe("Investigate"); expect(wf.agents!.tasks![0]!.assigned_to).toBe("researcher"); @@ -976,5 +987,111 @@ steps: expect(() => parseWorkflow(yaml)).toThrow(WorkflowParseError); expect(() => parseWorkflow(yaml)).toThrow(/assigned_to references unknown member/); }); + + it("parses agent seed task ids, dependencies, and tags", () => { + const yaml = ` +name: w +version: "1" +timeout: "5m" +agents: + enabled: true + team_name: "t" + members: + lead: + agent: a + reviewer: + agent: b + tasks: + - id: bootstrap + title: "Bootstrap" + description: "Create initial plan" + assigned_to: lead + tags: [core, bootstrap] + - id: review + title: "Review" + description: "Review implementation" + assigned_to: reviewer + depends_on: [bootstrap] + requires_plan_approval: true +steps: + s1: + worker: CODEX_CLI + instructions: "work" + capabilities: [READ] +`; + const wf = parseWorkflow(yaml); + expect(wf.agents?.tasks).toEqual([ + { + id: "bootstrap", + title: "Bootstrap", + description: "Create initial plan", + assigned_to: "lead", + tags: ["core", "bootstrap"], + }, + { + id: "review", + title: "Review", + description: "Review implementation", + assigned_to: "reviewer", + depends_on: ["bootstrap"], + requires_plan_approval: true, + }, + ]); + }); + + it("rejects task dependencies when a seed task id is missing", () => { + const yaml = ` +name: w +version: "1" +timeout: "5m" +agents: + enabled: true + team_name: "t" + members: + lead: + agent: a + tasks: + - title: "Bootstrap" + description: "Create initial plan" + - id: review + title: "Review" + description: "Review implementation" + depends_on: [bootstrap] +steps: + s1: + worker: CODEX_CLI + instructions: "work" + capabilities: [READ] +`; + expect(() => parseWorkflow(yaml)).toThrow(/agents.tasks\[0\].id is required when agents.tasks uses depends_on/); + }); + + it("rejects unknown task dependencies", () => { + const yaml = ` +name: w +version: "1" +timeout: "5m" +agents: + enabled: true + team_name: "t" + members: + lead: + agent: a + tasks: + - id: bootstrap + title: "Bootstrap" + description: "Create initial plan" + - id: review + title: "Review" + description: "Review implementation" + depends_on: [unknown] +steps: + s1: + worker: CODEX_CLI + instructions: "work" + capabilities: [READ] +`; + expect(() => parseWorkflow(yaml)).toThrow(/depends_on references unknown task "unknown"/); + }); }); }); diff --git a/test/unit/workflow/resolve-worker-task.test.ts b/test/unit/workflow/resolve-worker-task.test.ts index 9ef64d5..653f30c 100644 --- a/test/unit/workflow/resolve-worker-task.test.ts +++ b/test/unit/workflow/resolve-worker-task.test.ts @@ -190,6 +190,19 @@ describe("resolveTaskLike", () => { expect(resolved.env).toEqual(env); }); + it("passes through defaultArgs", () => { + const resolved = resolveTaskLike( + { + worker: "CODEX_CLI", + instructions: "do work", + capabilities: ["READ"], + defaultArgs: ["--sandbox", "danger-full-access"], + }, + baseWorkspace, + ); + expect(resolved.defaultArgs).toEqual(["--sandbox", "danger-full-access"]); + }); + it("omits env when not provided", () => { const resolved = resolveTaskLike( { @@ -264,6 +277,15 @@ describe("buildWorkerTask", () => { expect(task.env).toEqual(env); }); + it("passes defaultArgs to WorkerTask", () => { + const ac = new AbortController(); + const task = buildWorkerTask( + { ...baseResolved, defaultArgs: ["--sandbox", "danger-full-access"] }, + ac.signal, + ); + expect(task.defaultArgs).toEqual(["--sandbox", "danger-full-access"]); + }); + it("passes abortSignal to WorkerTask", () => { const ac = new AbortController(); const task = buildWorkerTask(baseResolved, ac.signal); diff --git a/test/unit/workflow/supervised-ipc-capability.test.ts b/test/unit/workflow/supervised-ipc-capability.test.ts new file mode 100644 index 0000000..6fe97a3 --- /dev/null +++ b/test/unit/workflow/supervised-ipc-capability.test.ts @@ -0,0 +1,70 @@ +import { describe, test, expect } from "bun:test"; + +import { + isBunRuntime, + supportsChildBunStdinPipe, + usesBunChildRuntime, +} from "../../../src/workflow/supervised-ipc-capability.js"; + +describe("isBunRuntime", () => { + test("returns true for the Bun executable path", () => { + expect(isBunRuntime("/home/reoring/.bun/bin/bun", {} as NodeJS.ProcessVersions)).toBe(true); + }); + + test("returns true for Bun-compiled binaries when process.versions.bun is present", () => { + expect( + isBunRuntime( + "/usr/local/bin/roboppi", + { bun: "1.3.6" } as NodeJS.ProcessVersions & { bun: string }, + ), + ).toBe(true); + }); + + test("returns false for non-Bun runtimes", () => { + expect(isBunRuntime("/usr/bin/node", {} as NodeJS.ProcessVersions)).toBe(false); + }); +}); + +describe("usesBunChildRuntime", () => { + test("returns true when the supervised child entrypoint is a Bun script", () => { + expect( + usesBunChildRuntime( + "/tmp/roboppi/src/index.ts", + "/usr/bin/node", + {} as NodeJS.ProcessVersions, + ), + ).toBe(true); + }); + + test("returns false when neither parent nor child use Bun", () => { + expect( + usesBunChildRuntime( + "/usr/local/bin/roboppi", + "/usr/bin/node", + {} as NodeJS.ProcessVersions, + ), + ).toBe(false); + }); +}); + +describe("supportsChildBunStdinPipe", () => { + test("returns true immediately for non-Bun child runtimes", async () => { + await expect( + supportsChildBunStdinPipe( + "/usr/local/bin/roboppi", + "/usr/bin/node", + {} as NodeJS.ProcessVersions, + ), + ).resolves.toBe(true); + }); + + test("returns false for Bun-compiled binaries that cannot be safely probed with -e", async () => { + await expect( + supportsChildBunStdinPipe( + "/usr/local/bin/roboppi", + "/usr/local/bin/roboppi", + { bun: "1.3.6" } as NodeJS.ProcessVersions & { bun: string }, + ), + ).resolves.toBe(false); + }); +}); diff --git a/tests/at/agents-cli.test.ts b/tests/at/agents-cli.test.ts index 5dcb615..66cd3fa 100644 --- a/tests/at/agents-cli.test.ts +++ b/tests/at/agents-cli.test.ts @@ -7,9 +7,10 @@ * Covers design §11.3. */ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; import path from "node:path"; +import { tmpdir } from "node:os"; const REPO_ROOT = process.cwd(); const TEST_TMP_ROOT = path.join(REPO_ROOT, ".roboppi-loop", "tmp", "at-agents-cli"); @@ -50,13 +51,38 @@ function createCleanEnv(extra?: Record): NodeJS.Proc const CLI_TIMEOUT_MS = 45_000; // hard timeout — must be lower than AT_TIMEOUT so the test runner doesn't race the harness kill +function shellQuote(arg: string): string { + return `'${arg.replace(/'/g, `'\"'\"'`)}'`; +} + async function runAgentsCli(args: string[], env?: NodeJS.ProcessEnv, stdinData?: string): Promise { + const stdinDir = stdinData !== undefined + ? await mkdtemp(path.join(tmpdir(), "roboppi-agents-stdin-")) + : null; + const stdinPath = stdinDir ? path.join(stdinDir, "input.json") : null; + if (stdinPath) { + await writeFile(stdinPath, stdinData ?? "", "utf8"); + } + return new Promise((resolve) => { - const child = spawn(process.execPath, ["run", "src/cli.ts", "--", "agents", ...args], { - cwd: REPO_ROOT, - env: env ?? createCleanEnv(), - stdio: ["pipe", "pipe", "pipe"], - }) as ChildProcessWithoutNullStreams; + const child = stdinPath + ? spawn( + "bash", + [ + "-lc", + `${shellQuote(process.execPath)} run src/cli.ts -- agents ${args.map(shellQuote).join(" ")} < ${shellQuote(stdinPath)}`, + ], + { + cwd: REPO_ROOT, + env: env ?? createCleanEnv(), + stdio: ["ignore", "pipe", "pipe"], + }, + ) + : spawn(process.execPath, ["run", "src/cli.ts", "--", "agents", ...args], { + cwd: REPO_ROOT, + env: env ?? createCleanEnv(), + stdio: ["pipe", "pipe", "pipe"], + }); let stdout = ""; let stderr = ""; @@ -67,10 +93,9 @@ async function runAgentsCli(args: string[], env?: NodeJS.ProcessEnv, stdinData?: child.stdout.on("data", (chunk: string) => { stdout += chunk; }); child.stderr.on("data", (chunk: string) => { stderr += chunk; }); - if (stdinData !== undefined) { - child.stdin.write(stdinData); + if (!stdinPath && child.stdin) { + child.stdin.end(); } - child.stdin.end(); // Handle process error (e.g. ENOENT, spawn failure) child.once("error", (err) => { @@ -84,6 +109,7 @@ async function runAgentsCli(args: string[], env?: NodeJS.ProcessEnv, stdinData?: if (settled) return; settled = true; clearTimeout(timer); + void rm(stdinDir ?? "", { recursive: true, force: true }); resolve({ code, signal, stdout, stderr }); }); @@ -94,6 +120,7 @@ async function runAgentsCli(args: string[], env?: NodeJS.ProcessEnv, stdinData?: child.kill("SIGKILL"); // Wait for the child to actually close (or resolve after a grace period) const graceTimer = setTimeout(() => { + void rm(stdinDir ?? "", { recursive: true, force: true }); resolve({ code: null, signal: "SIGKILL" as NodeJS.Signals, @@ -103,6 +130,7 @@ async function runAgentsCli(args: string[], env?: NodeJS.ProcessEnv, stdinData?: }, 3_000); child.once("close", (code, signal) => { clearTimeout(graceTimer); + void rm(stdinDir ?? "", { recursive: true, force: true }); resolve({ code, signal: signal ?? ("SIGKILL" as NodeJS.Signals), @@ -233,6 +261,39 @@ describe("roboppi agents CLI roundtrip", () => { expect(recv2Json.messages.length).toBe(0); }, AT_TIMEOUT); + it("status set + get roundtrip", async () => { + await runAgentsCli(["init", "--context", contextDir, "--team", "test-team"]); + + const env = createCleanEnv({ + ROBOPPI_AGENTS_CONTEXT_DIR: contextDir, + ROBOPPI_AGENTS_MEMBER_ID: "lead", + }); + + const setResult = await runAgentsCli([ + "status", "set", + "--context", contextDir, + "--summary", "Implementer is addressing the current blocker.", + "--blocker", "manual verification missing", + "--next", "wait for implementer patch", + "--next", "rerun tester", + ], env); + const setJson = parseJsonStdout(setResult); + expect(setJson.ok).toBe(true); + expect(setJson.status.owner_member_id).toBe("lead"); + expect(setJson.status.summary).toBe("Implementer is addressing the current blocker."); + expect(setJson.status.blockers).toEqual(["manual verification missing"]); + expect(setJson.status.next_actions).toEqual(["wait for implementer patch", "rerun tester"]); + + const getResult = await runAgentsCli([ + "status", "get", + "--context", contextDir, + ], env); + const getJson = parseJsonStdout(getResult); + expect(getJson.ok).toBe(true); + expect(getJson.status.summary).toBe("Implementer is addressing the current blocker."); + expect(getJson.status.owner_member_id).toBe("lead"); + }, AT_TIMEOUT); + it("message reply sends to original sender and acks the claimed message", async () => { await runAgentsCli(["init", "--context", contextDir, "--team", "test-team"]); @@ -390,6 +451,47 @@ describe("roboppi agents CLI roundtrip", () => { expect(doneJson.tasks.length).toBe(1); }, AT_TIMEOUT); + it("tasks supersede roundtrip", async () => { + await runAgentsCli(["init", "--context", contextDir, "--team", "test-team"]); + + const env = createCleanEnv({ + ROBOPPI_AGENTS_CONTEXT_DIR: contextDir, + ROBOPPI_AGENTS_MEMBER_ID: "lead", + }); + + const addResult = await runAgentsCli([ + "tasks", "add", + "--context", contextDir, + "--title", "Supersede CLI Task", + "--description", "Replace me", + ], env); + const addJson = parseJsonStdout(addResult); + expect(addJson.ok).toBe(true); + + const taskId = addJson.task_id; + const supersedeResult = await runAgentsCli([ + "tasks", "supersede", + "--context", contextDir, + "--task-id", taskId, + "--member", "lead", + "--reason", "stale contract", + "--replacement-task-id", "replacement-task", + ], env); + const supersedeJson = parseJsonStdout(supersedeResult); + expect(supersedeJson.ok).toBe(true); + + const listResult = await runAgentsCli([ + "tasks", "list", + "--context", contextDir, + "--status", "superseded", + ], env); + const listJson = parseJsonStdout(listResult); + expect(listJson.ok).toBe(true); + expect(listJson.tasks.length).toBe(1); + expect(listJson.tasks[0].task_id).toBe(taskId); + expect(listJson.tasks[0].status).toBe("superseded"); + }, AT_TIMEOUT); + it("housekeep returns JSON", async () => { await runAgentsCli(["init", "--context", contextDir, "--team", "test-team"]); @@ -426,6 +528,77 @@ describe("roboppi agents CLI roundtrip", () => { expect(json.error).toBeTruthy(); }, AT_TIMEOUT); + it("tasks claim rejects claiming a task assigned to another member", async () => { + await runAgentsCli(["init", "--context", contextDir, "--team", "test-team"]); + const leadEnv = createCleanEnv({ + ROBOPPI_AGENTS_CONTEXT_DIR: contextDir, + ROBOPPI_AGENTS_MEMBER_ID: "lead", + }); + + const upsertResult = await runAgentsCli([ + "members", "upsert", + "--context", contextDir, + "--member", "manual_verifier", + "--agent", "manual-verifier-agent", + ], leadEnv); + expect(parseJsonStdout(upsertResult).ok).toBe(true); + + const addResult = await runAgentsCli([ + "tasks", "add", + "--context", contextDir, + "--title", "Manual preflight", + "--description", "Run manual verification", + "--assigned-to", "manual_verifier", + ], leadEnv); + const addJson = parseJsonStdout(addResult); + expect(addJson.ok).toBe(true); + + const claimResult = await runAgentsCli([ + "tasks", "claim", + "--context", contextDir, + "--task-id", addJson.task_id, + "--member", "lead", + ], leadEnv); + const claimJson = parseJsonStdout(claimResult); + expect(claimJson.ok).toBe(false); + expect(claimJson.error).toContain('assigned to "manual_verifier"'); + }, AT_TIMEOUT); + + it("tasks claim rejects member spoofing when caller identity is set", async () => { + await runAgentsCli(["init", "--context", contextDir, "--team", "test-team"]); + const leadEnv = createCleanEnv({ + ROBOPPI_AGENTS_CONTEXT_DIR: contextDir, + ROBOPPI_AGENTS_MEMBER_ID: "lead", + }); + + const upsertResult = await runAgentsCli([ + "members", "upsert", + "--context", contextDir, + "--member", "manual_verifier", + "--agent", "manual-verifier-agent", + ], leadEnv); + expect(parseJsonStdout(upsertResult).ok).toBe(true); + + const addResult = await runAgentsCli([ + "tasks", "add", + "--context", contextDir, + "--title", "Manual preflight", + "--description", "Run manual verification", + ], leadEnv); + const addJson = parseJsonStdout(addResult); + expect(addJson.ok).toBe(true); + + const claimResult = await runAgentsCli([ + "tasks", "claim", + "--context", contextDir, + "--task-id", addJson.task_id, + "--member", "manual_verifier", + ], leadEnv); + const claimJson = parseJsonStdout(claimResult); + expect(claimJson.ok).toBe(false); + expect(claimJson.error).toContain('does not match caller "lead"'); + }, AT_TIMEOUT); + it("all stdout output is valid JSON", async () => { await runAgentsCli(["init", "--context", contextDir, "--team", "test-team"]); diff --git a/tests/unit/agents/agents-tab.test.ts b/tests/unit/agents/agents-tab.test.ts index 32d8fd3..eca76c1 100644 --- a/tests/unit/agents/agents-tab.test.ts +++ b/tests/unit/agents/agents-tab.test.ts @@ -8,21 +8,35 @@ import { describe, it, expect } from "bun:test"; import { renderAgentsTab } from "../../../src/tui/components/tabs/agents-tab.js"; import { RingBuffer } from "../../../src/tui/ring-buffer.js"; -import type { WorkflowUiState, AgentActivityEntry } from "../../../src/tui/state-store.js"; +import type { WorkflowUiState, AgentActivityEntry, AgentRosterEntry, AgentRuntimeStats, StepUiState } from "../../../src/tui/state-store.js"; -function makeState(entries: AgentActivityEntry[] = [], activityLines: string[] = []): WorkflowUiState { +function makeState( + entries: AgentActivityEntry[] = [], + activityLines: string[] = [], + opts?: { + roster?: AgentRosterEntry[]; + runtime?: AgentRuntimeStats[]; + steps?: StepUiState[]; + }, +): WorkflowUiState { const buf = new RingBuffer({ maxLines: 100, maxBytes: 10000 }); for (const l of activityLines) buf.push(l); + const steps = new Map((opts?.steps ?? []).map((step) => [step.stepId, step])); + const roster = opts?.roster ?? []; + const runtime = opts?.runtime ?? []; return { - steps: new Map(), - stepOrder: [], + steps, + stepOrder: [...steps.keys()], followMode: "running", selectedTab: "agents", coreLogs: new RingBuffer({ maxLines: 100, maxBytes: 10000 }), warnings: new RingBuffer({ maxLines: 100, maxBytes: 10000 }), agentActivity: buf, agentEntries: entries, + agentRoster: new Map(roster.map((entry) => [entry.memberId, entry])), + agentRosterOrder: roster.map((entry) => entry.memberId), + agentRuntime: new Map(runtime.map((entry) => [entry.memberId, entry])), chatMessages: [], chatInputActive: false, chatInputBuffer: "", @@ -37,6 +51,85 @@ describe("renderAgentsTab", () => { expect(output).toContain("No agent activity"); }); + it("renders cross-agent summary rows in the global Agents tab", () => { + const state = makeState([], [], { + roster: [ + { memberId: "implementer", name: "implementer", role: "member", agentId: "implementer" }, + { memberId: "manual_verifier", name: "manual_verifier", role: "dormant", agentId: "manual_verifier" }, + ], + runtime: [ + { + memberId: "implementer", + dispatchCount: 5, + restartCount: 2, + totalEstimatedTokens: 23_400, + totalInstructionBytes: 32_768, + lastStartedAt: Date.parse("2026-03-09T01:00:00.000Z"), + lastStoppedAt: Date.parse("2026-03-09T01:05:00.000Z"), + lastDispatchStartedAt: Date.parse("2026-03-09T01:00:10.000Z"), + lastDispatchFinishedAt: Date.parse("2026-03-09T01:00:50.000Z"), + lastDispatchDurationMs: 40_000, + totalDispatchActiveMs: 125_000, + }, + ], + steps: [ + { + stepId: "_agent:implementer", + status: "RUNNING", + phase: "executing", + iteration: 1, + maxIterations: 1, + startedAt: Date.parse("2026-03-09T01:00:00.000Z"), + logs: { + stdout: new RingBuffer({ maxLines: 100, maxBytes: 10000 }), + stderr: new RingBuffer({ maxLines: 100, maxBytes: 10000 }), + progress: new RingBuffer({ maxLines: 100, maxBytes: 10000 }), + }, + patches: { + byId: new Map(), + order: [], + byFilePath: new Map(), + }, + }, + ], + }); + + const output = renderAgentsTab(state, 110, 20); + expect(output).toContain("Agent Summary"); + expect(output).toContain("implementer"); + expect(output).toContain("RUNNING"); + expect(output).toContain("manual_verifier"); + expect(output).toContain("DORMANT"); + expect(output).toContain("Disp"); + expect(output).toContain("Re"); + expect(output).toContain("Tok"); + expect(output).toContain("Prompt"); + expect(output).toContain("23k"); + expect(output).toContain("32K"); + }); + + it("uses available height to show all summary rows when activity is empty", () => { + const roster: AgentRosterEntry[] = Array.from({ length: 12 }, (_, i) => ({ + memberId: `agent_${i}`, + name: `agent_${i}`, + role: i < 5 ? "member" : "dormant", + agentId: `profile_${i}`, + })); + const runtime: AgentRuntimeStats[] = roster.map((entry, i) => ({ + memberId: entry.memberId, + dispatchCount: i, + restartCount: 0, + totalDispatchActiveMs: i * 1000, + lastStartedAt: Date.parse(`2026-03-09T01:${String(i).padStart(2, "0")}:00.000Z`), + })); + + const output = renderAgentsTab(makeState([], [], { roster, runtime }), 110, 24); + expect(output).toContain("agent_0"); + expect(output).toContain("agent_11"); + expect(output).not.toContain("+"); + expect(output).not.toContain("Recent Activity"); + }); + it("renders message sent entries", () => { const entries: AgentActivityEntry[] = [ { @@ -127,6 +220,36 @@ describe("renderAgentsTab", () => { const lines = output.split("\n"); expect(lines.length).toBe(10); }); + + it("keeps filtered agent activity view focused on activity", () => { + const state = makeState([ + { + ts: Date.now(), + type: "agent_message_sent", + teamId: "team-1", + id: "msg-1", + memberId: "reviewer", + targetMemberId: "lead", + label: "review", + }, + ], [], { + roster: [ + { memberId: "reviewer", name: "reviewer", role: "member", agentId: "reviewer" }, + ], + runtime: [ + { + memberId: "reviewer", + dispatchCount: 2, + restartCount: 1, + totalDispatchActiveMs: 15_000, + }, + ], + }); + + const output = renderAgentsTab(state, 80, 20, "reviewer"); + expect(output).toContain("Activity: reviewer"); + expect(output).not.toContain("Agent Summary"); + }); }); describe("metadata-only display policy", () => { diff --git a/tests/unit/agents/coordinator.test.ts b/tests/unit/agents/coordinator.test.ts index 601b5de..b4de1de 100644 --- a/tests/unit/agents/coordinator.test.ts +++ b/tests/unit/agents/coordinator.test.ts @@ -11,8 +11,13 @@ import path from "node:path"; const TEST_TMP_ROOT = path.join(process.cwd(), ".roboppi-loop", "tmp", "unit-coordinator"); import { AgentCoordinator } from "../../../src/agents/coordinator.js"; -import { initAgentsContext, writeMembersConfig } from "../../../src/agents/store.js"; -import { agentEventsPath } from "../../../src/agents/paths.js"; +import { + initAgentsContext, + writeMembersConfig, + deliverMessage, + recvMessages, +} from "../../../src/agents/store.js"; +import { agentEventsPath, mailboxEventsPath } from "../../../src/agents/paths.js"; import type { StepRunResult } from "../../../src/workflow/executor.js"; import type { StepDefinition } from "../../../src/workflow/types.js"; import type { ExecEventSink, ExecEvent } from "../../../src/tui/exec-event.js"; @@ -412,11 +417,191 @@ describe("AgentCoordinator bounded shutdown (Spec 3.2)", () => { }); }); +describe("AgentCoordinator mailbox previews", () => { + it("emits in-memory body previews for chat without persisting message bodies", async () => { + const { teamId } = await initAgentsContext({ + contextDir, + teamName: "test", + leadMemberId: "lead", + members: [ + { member_id: "lead", name: "Lead", role: "team_lead" }, + { member_id: "worker", name: "Worker", role: "worker" }, + ], + }); + + const events: ExecEvent[] = []; + const coord = new AgentCoordinator({ + contextDir, + sink: { emit(event: ExecEvent) { events.push(event); } }, + teamId, + }); + + await coord.start(); + + const body = "hello from lead to worker"; + const { messageId } = await deliverMessage({ + contextDir, + teamId, + fromMemberId: "lead", + fromName: "Lead", + toMemberId: "worker", + topic: "chat", + body, + }); + + await (coord as unknown as { tailEvents(): Promise }).tailEvents(); + + const sentEvent = events.find( + (event): event is Extract => + event.type === "agent_message_sent" && event.messageId === messageId, + ); + expect(sentEvent?.bodyPreview).toBe(body); + + await recvMessages({ contextDir, memberId: "worker", claim: true, max: 1 }); + await (coord as unknown as { tailEvents(): Promise }).tailEvents(); + + const receivedEvent = events.find( + (event): event is Extract => + event.type === "agent_message_received" && event.messageId === messageId, + ); + expect(receivedEvent?.bodyPreview).toBe(body); + + const mailboxEvents = await readFile(mailboxEventsPath(contextDir), "utf-8"); + expect(mailboxEvents).not.toContain(body); + + await coord.stop(); + }); +}); + // ------------------------------------------------------------------------- // Dynamic membership reconcile // ------------------------------------------------------------------------- describe("AgentCoordinator reconcile (dynamic membership)", () => { + it("respawns a desired member when its ResidentAgent stops unexpectedly", async () => { + await initAgentsContext({ + contextDir, + teamName: "test", + leadMemberId: "lead", + members: [ + { member_id: "lead", name: "Lead", role: "team_lead" }, + { member_id: "manual_verifier", name: "Manual Verifier", role: "worker" }, + ], + }); + + const events: ExecEvent[] = []; + const trackingSink: ExecEventSink = { emit(e: ExecEvent) { events.push(e); } }; + + const coord = new AgentCoordinator({ + contextDir, + sink: trackingSink, + workspaceDir: process.cwd(), + members: { + lead: { agent: "lead_agent" }, + manual_verifier: { agent: "manual_verifier" }, + }, + leadMemberId: "lead", + teamId: "test-team-id", + reconcileIntervalMs: 100, + teammateShutdownWaitMs: 200, + }); + + await coord.start(); + await new Promise((r) => setTimeout(r, 150)); + + const coordInternals = coord as unknown as { + residentAgents: Array<{ memberId: string; isRunning: boolean; stop(): void }>; + }; + const originalAgent = coordInternals.residentAgents.find((a) => a.memberId === "manual_verifier"); + expect(originalAgent).toBeDefined(); + expect(originalAgent?.isRunning).toBe(true); + + originalAgent!.stop(); + expect(originalAgent?.isRunning).toBe(false); + + await new Promise((r) => setTimeout(r, 300)); + + const runningEvents = events.filter( + (e) => e.type === "step_state" && (e as any).stepId === "_agent:manual_verifier" && (e as any).status === "RUNNING", + ); + expect(runningEvents.length).toBeGreaterThanOrEqual(2); + + const liveAgents = coordInternals.residentAgents.filter( + (a) => a.memberId === "manual_verifier" && a.isRunning, + ); + expect(liveAgents).toHaveLength(1); + + await coord.stop(); + }); + + it("does not respawn a desired member after shutdown begins mid-reconcile", async () => { + await initAgentsContext({ + contextDir, + teamName: "test", + leadMemberId: "lead", + members: [ + { member_id: "lead", name: "Lead", role: "team_lead" }, + { member_id: "manual_verifier", name: "Manual Verifier", role: "worker" }, + ], + }); + + const events: ExecEvent[] = []; + const trackingSink: ExecEventSink = { emit(e: ExecEvent) { events.push(e); } }; + + const coord = new AgentCoordinator({ + contextDir, + sink: trackingSink, + workspaceDir: process.cwd(), + members: { + lead: { agent: "lead_agent" }, + manual_verifier: { agent: "manual_verifier" }, + }, + leadMemberId: "lead", + teamId: "test-team-id", + reconcileIntervalMs: 1_000, + teammateShutdownWaitMs: 200, + }); + + await coord.start(); + await new Promise((r) => setTimeout(r, 150)); + + const coordInternals = coord as unknown as { + stopped: boolean; + residentAgents: Array<{ memberId: string; isRunning: boolean; stop(): void }>; + removeStoppedResidentAgents(): void; + reconcile(): Promise; + }; + + const originalAgent = coordInternals.residentAgents.find((a) => a.memberId === "manual_verifier"); + expect(originalAgent).toBeDefined(); + originalAgent!.stop(); + expect(originalAgent?.isRunning).toBe(false); + + const runningEventsBefore = events.filter( + (e) => e.type === "step_state" && (e as any).stepId === "_agent:manual_verifier" && (e as any).status === "RUNNING", + ).length; + + const originalRemoveStopped = coordInternals.removeStoppedResidentAgents.bind(coordInternals); + coordInternals.removeStoppedResidentAgents = () => { + coordInternals.stopped = true; + originalRemoveStopped(); + }; + + await coordInternals.reconcile(); + + const runningEventsAfter = events.filter( + (e) => e.type === "step_state" && (e as any).stepId === "_agent:manual_verifier" && (e as any).status === "RUNNING", + ).length; + expect(runningEventsAfter).toBe(runningEventsBefore); + + const liveAgents = coordInternals.residentAgents.filter( + (a) => a.memberId === "manual_verifier" && a.isRunning, + ); + expect(liveAgents).toHaveLength(0); + + await coord.stop(); + }); + it("spawns a newly-added member as a ResidentAgent", async () => { await initAgentsContext({ contextDir, diff --git a/tests/unit/agents/status-store.test.ts b/tests/unit/agents/status-store.test.ts new file mode 100644 index 0000000..bcddf2d --- /dev/null +++ b/tests/unit/agents/status-store.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import path from "node:path"; + +import { + readWorkflowStatus, + writeWorkflowStatus, + clearWorkflowStatus, +} from "../../../src/agents/status-store.js"; + +const TEST_TMP_ROOT = path.join(process.cwd(), ".roboppi-loop", "tmp", "unit-status-store"); + +let contextDir: string; + +beforeEach(async () => { + await mkdir(TEST_TMP_ROOT, { recursive: true }); + contextDir = await mkdtemp(path.join(TEST_TMP_ROOT, "status-store-")); +}); + +afterEach(async () => { + await rm(contextDir, { recursive: true, force: true }); +}); + +describe("workflow status store", () => { + it("writes and reads workflow status summary", async () => { + const written = await writeWorkflowStatus({ + contextDir, + ownerMemberId: "planner", + summary: "Implementer is fixing the current blocker.", + blockers: ["kind bootstrap fails"], + nextActions: ["rerun fast gates", "refresh issue index"], + }); + + const read = await readWorkflowStatus(contextDir); + + expect(read).toBeTruthy(); + expect(read?.version).toBe("1"); + expect(read?.owner_member_id).toBe("planner"); + expect(read?.summary).toBe("Implementer is fixing the current blocker."); + expect(read?.blockers).toEqual(["kind bootstrap fails"]); + expect(read?.next_actions).toEqual(["rerun fast gates", "refresh issue index"]); + expect(read?.updated_at).toBe(written.updated_at); + }); + + it("clears workflow status summary", async () => { + await writeWorkflowStatus({ + contextDir, + ownerMemberId: "planner", + summary: "Temporary summary", + }); + + await clearWorkflowStatus(contextDir); + + expect(await readWorkflowStatus(contextDir)).toBeNull(); + }); +}); diff --git a/tests/unit/agents/task-store.test.ts b/tests/unit/agents/task-store.test.ts index 30fb9d9..eae1095 100644 --- a/tests/unit/agents/task-store.test.ts +++ b/tests/unit/agents/task-store.test.ts @@ -18,6 +18,7 @@ import { listTasks, claimTask, completeTask, + supersedeTask, validateArtifactPath, } from "../../../src/agents/task-store.js"; import { @@ -294,3 +295,51 @@ describe("metadata-only task events", () => { expect(eventsRaw).toContain("Public Title"); }); }); + + +describe("supersedeTask", () => { + it("moves a pending task to superseded/ with metadata", async () => { + const { taskId } = await addTask({ contextDir, title: "Supersede Me", description: "" }); + + const result = await supersedeTask(contextDir, taskId, "lead", "stale contract", "replacement-task"); + expect(result.ok).toBe(true); + + const supersededEntries = await readdir(tasksStatusDir(contextDir, "superseded")); + expect(supersededEntries).toContain(`${taskId}.json`); + + const raw = await readFile( + path.join(tasksStatusDir(contextDir, "superseded"), `${taskId}.json`), + "utf-8", + ); + const task = JSON.parse(raw); + expect(task.status).toBe("superseded"); + expect(task.superseded_by).toBe("lead"); + expect(task.supersede_reason).toBe("stale contract"); + expect(task.replacement_task_id).toBe("replacement-task"); + }); + + it("moves an in_progress task to superseded/", async () => { + const { taskId } = await addTask({ contextDir, title: "Claim Then Supersede", description: "" }); + await claimTask(contextDir, taskId, "alice"); + + const result = await supersedeTask(contextDir, taskId, "lead", "replaced"); + expect(result.ok).toBe(true); + + const supersededEntries = await readdir(tasksStatusDir(contextDir, "superseded")); + expect(supersededEntries).toContain(`${taskId}.json`); + }); + + it("emits task_superseded event", async () => { + const { taskId } = await addTask({ contextDir, title: "Supersede Event", description: "" }); + + await supersedeTask(contextDir, taskId, "lead", "duplicate", "replacement-task"); + + const eventsRaw = await readFile(tasksEventsPath(contextDir), "utf-8"); + const events = eventsRaw.trim().split("\n").map((l) => JSON.parse(l)); + const superseded = events.find((e: any) => e.type === "task_superseded"); + expect(superseded).toBeTruthy(); + expect(superseded.by).toBe("lead"); + expect(superseded.reason).toBe("duplicate"); + expect(superseded.replacement_task_id).toBe("replacement-task"); + }); +}); diff --git a/tests/unit/tui/agent-overview-tab.test.ts b/tests/unit/tui/agent-overview-tab.test.ts index 8574264..98c2c52 100644 --- a/tests/unit/tui/agent-overview-tab.test.ts +++ b/tests/unit/tui/agent-overview-tab.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "bun:test"; import { renderAgentOverviewTab } from "../../../src/tui/components/tabs/agent-overview-tab.js"; import { RingBuffer } from "../../../src/tui/ring-buffer.js"; -import type { WorkflowUiState, StepUiState } from "../../../src/tui/state-store.js"; +import type { WorkflowUiState, StepUiState, AgentRosterEntry } from "../../../src/tui/state-store.js"; function makeStep(stepId: string, overrides?: Partial): StepUiState { const logOpts = { maxLines: 100, maxBytes: 10000 }; @@ -24,7 +24,7 @@ function makeStep(stepId: string, overrides?: Partial): StepUiState }; } -function makeState(steps: StepUiState[]): WorkflowUiState { +function makeState(steps: StepUiState[], roster: AgentRosterEntry[] = []): WorkflowUiState { const map = new Map(); for (const s of steps) map.set(s.stepId, s); const logOpts = { maxLines: 100, maxBytes: 10000 }; @@ -37,6 +37,9 @@ function makeState(steps: StepUiState[]): WorkflowUiState { warnings: new RingBuffer(logOpts), agentActivity: new RingBuffer(logOpts), agentEntries: [], + agentRoster: new Map(roster.map((entry) => [entry.memberId, entry])), + agentRosterOrder: roster.map((entry) => entry.memberId), + agentRuntime: new Map(), chatMessages: [], chatInputActive: false, chatInputBuffer: "", @@ -51,8 +54,8 @@ describe("renderAgentOverviewTab", () => { step.logs.stdout.push("Editing lines 15-30"); step.logs.stdout.push("Running: bun test"); - const state = makeState([step]); - const output = renderAgentOverviewTab(state, step, "impl", 80, 30); + const state = makeState([step], [{ memberId: "impl", name: "impl", role: "member", agentId: "implementer" }]); + const output = renderAgentOverviewTab(state, step, state.agentRoster.get("impl"), "impl", 80, 30); expect(output).toContain("Current Output"); expect(output).toContain("Reading src/counter.ts..."); @@ -64,8 +67,8 @@ describe("renderAgentOverviewTab", () => { step.patches.byFilePath.set("src/cli.ts", ["p1"]); step.patches.byFilePath.set("src/counter.ts", ["p2", "p3", "p4"]); - const state = makeState([step]); - const output = renderAgentOverviewTab(state, step, "impl", 80, 30); + const state = makeState([step], [{ memberId: "impl", name: "impl", role: "member", agentId: "implementer" }]); + const output = renderAgentOverviewTab(state, step, state.agentRoster.get("impl"), "impl", 80, 30); expect(output).toContain("Modified Files"); expect(output).toContain("src/cli.ts"); @@ -74,17 +77,112 @@ describe("renderAgentOverviewTab", () => { it("shows no output section when stdout is empty", () => { const step = makeStep("_agent:impl"); - const state = makeState([step]); - const output = renderAgentOverviewTab(state, step, "impl", 80, 30); + const state = makeState([step], [{ memberId: "impl", name: "impl", role: "member", agentId: "implementer" }]); + const output = renderAgentOverviewTab(state, step, state.agentRoster.get("impl"), "impl", 80, 30); expect(output).not.toContain("Current Output"); }); it("shows no modified files section when patches are empty", () => { const step = makeStep("_agent:impl"); - const state = makeState([step]); - const output = renderAgentOverviewTab(state, step, "impl", 80, 30); + const state = makeState([step], [{ memberId: "impl", name: "impl", role: "member", agentId: "implementer" }]); + const output = renderAgentOverviewTab(state, step, state.agentRoster.get("impl"), "impl", 80, 30); expect(output).not.toContain("Modified Files"); }); + + it("renders dormant agents even when no step is running", () => { + const state = makeState([], [{ memberId: "manual_verifier", name: "manual_verifier", role: "dormant", agentId: "manual_verifier" }]); + const output = renderAgentOverviewTab(state, undefined, state.agentRoster.get("manual_verifier"), "manual_verifier", 80, 30); + + expect(output).toContain("manual_verifier"); + expect(output).toContain("DORMANT"); + expect(output).toContain("sleeping"); + }); + + it("renders runtime metrics for agent dispatches and restarts", () => { + const step = makeStep("_agent:impl", { + startedAt: Date.parse("2026-03-09T00:10:00.000Z"), + phase: "ready", + }); + const state = makeState([step], [{ memberId: "impl", name: "impl", role: "member", agentId: "implementer" }]); + state.agentRuntime.set("impl", { + memberId: "impl", + dispatchCount: 3, + restartCount: 1, + tokenSampleCount: 1, + totalEstimatedTokens: 12345, + lastEstimatedTokens: 2345, + totalInstructionBytes: 16_384, + lastInstructionBytes: 4_096, + lastStartedAt: Date.parse("2026-03-09T00:10:00.000Z"), + lastStoppedAt: Date.parse("2026-03-09T00:12:00.000Z"), + lastDispatchStartedAt: Date.parse("2026-03-09T00:10:10.000Z"), + lastDispatchFinishedAt: Date.parse("2026-03-09T00:10:40.000Z"), + lastDispatchDurationMs: 30_000, + totalDispatchActiveMs: 95_000, + }); + + const output = renderAgentOverviewTab(state, step, state.agentRoster.get("impl"), "impl", 100, 30); + + expect(output).toContain("Dispatches:"); + expect(output).toContain("3"); + expect(output).toContain("Restarts:"); + expect(output).toContain("1"); + expect(output).toContain("Tokens:"); + expect(output).toContain("12k"); + expect(output).toContain("last 2.3k"); + expect(output).toContain("Prompt:"); + expect(output).toContain("16KiB"); + expect(output).toContain("last 4.0KiB"); + expect(output).toContain("2026-03-09 00:10:00"); + expect(output).toContain("2026-03-09 00:12:00"); + expect(output).toContain("2026-03-09 00:10:10 -> 2026-03-09 00:10:40 (30s)"); + expect(output).toContain("Busy Time:"); + expect(output).toContain("1m35s"); + }); + + it("renders current instructions for an active dispatch", () => { + const step = makeStep("_agent:planner", { + startedAt: Date.parse("2026-03-09T00:10:00.000Z"), + phase: "executing", + }); + const state = makeState([step], [{ memberId: "planner", name: "planner", role: "member", agentId: "planner" }]); + state.agentRuntime.set("planner", { + memberId: "planner", + dispatchCount: 1, + restartCount: 0, + totalDispatchActiveMs: 0, + currentlyDispatchingSince: Date.parse("2026-03-09T00:10:10.000Z"), + currentInstructions: "You are planner.\nRead todo.md\nReply to lead.", + lastInstructions: "You are planner.\nRead todo.md\nReply to lead.", + }); + + const output = renderAgentOverviewTab(state, step, state.agentRoster.get("planner"), "planner", 80, 30); + + expect(output).toContain("Current Instructions"); + expect(output).toContain("You are planner."); + expect(output).toContain("Read todo.md"); + expect(output).toContain("Reply to lead."); + }); + + it("renders last instructions after a dispatch has finished", () => { + const step = makeStep("_agent:planner", { + startedAt: Date.parse("2026-03-09T00:10:00.000Z"), + phase: "ready", + }); + const state = makeState([step], [{ memberId: "planner", name: "planner", role: "member", agentId: "planner" }]); + state.agentRuntime.set("planner", { + memberId: "planner", + dispatchCount: 1, + restartCount: 0, + totalDispatchActiveMs: 30_000, + lastInstructions: "You are planner.\nSummarize issue state.", + }); + + const output = renderAgentOverviewTab(state, step, state.agentRoster.get("planner"), "planner", 80, 30); + + expect(output).toContain("Last Instructions"); + expect(output).toContain("Summarize issue state."); + }); }); diff --git a/tests/unit/tui/detail-pane.test.ts b/tests/unit/tui/detail-pane.test.ts index 7ff8bd6..02808c1 100644 --- a/tests/unit/tui/detail-pane.test.ts +++ b/tests/unit/tui/detail-pane.test.ts @@ -15,6 +15,13 @@ describe("resolveEffectiveTab", () => { expect(resolveEffectiveTab("logs", "build")).toBe("logs"); }); + it("preserves raw_logs and usage tabs across step/agent", () => { + expect(resolveEffectiveTab("raw_logs", "_agent:impl")).toBe("raw_logs"); + expect(resolveEffectiveTab("raw_logs", "build")).toBe("raw_logs"); + expect(resolveEffectiveTab("usage", "_agent:impl")).toBe("usage"); + expect(resolveEffectiveTab("usage", "build")).toBe("usage"); + }); + it("maps diffs to agents for agent context", () => { expect(resolveEffectiveTab("diffs", "_agent:impl")).toBe("agents"); }); diff --git a/tests/unit/tui/logs-tab.test.ts b/tests/unit/tui/logs-tab.test.ts new file mode 100644 index 0000000..b7d9a2e --- /dev/null +++ b/tests/unit/tui/logs-tab.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "bun:test"; +import { renderLogsTab } from "../../../src/tui/components/tabs/logs-tab.js"; +import { RingBuffer } from "../../../src/tui/ring-buffer.js"; +import type { StepUiState } from "../../../src/tui/state-store.js"; + +function makeStep(): StepUiState { + const logOpts = { maxLines: 100, maxBytes: 10000 }; + return { + stepId: "_agent:lead", + status: "RUNNING", + iteration: 0, + maxIterations: 1, + logs: { + stdout: new RingBuffer(logOpts), + stderr: new RingBuffer(logOpts), + progress: new RingBuffer(logOpts), + }, + patches: { + byId: new Map(), + order: [], + byFilePath: new Map(), + }, + }; +} + +describe("renderLogsTab", () => { + it("renders plain logs unchanged", () => { + const step = makeStep(); + step.logs.stdout.push("hello world"); + + const output = renderLogsTab(step, 80, 10); + expect(output).toContain("hello world"); + expect(output).toContain("[worker stdout]"); + }); + + it("summarizes claude structured JSON into thinking, tool, and usage lines", () => { + const step = makeStep(); + step.logs.progress.push(JSON.stringify({ + type: "message", + role: "assistant", + content: [ + { type: "thinking", thinking: "Need to inspect the current repository state before making changes." }, + { + type: "tool_use", + name: "Bash", + input: { + command: "git status --short", + description: "Check worktree status", + }, + }, + ], + usage: { + input_tokens: 1, + output_tokens: 8, + cache_read_input_tokens: 25498, + cache_creation_input_tokens: 7058, + }, + })); + + const output = renderLogsTab(step, 180, 20); + expect(output).toContain("[thinking]"); + expect(output).toContain("Need to inspect the current repository state"); + expect(output).toContain("[tool call]"); + expect(output).toContain("Bash: Check worktree status | git status --short"); + expect(output).toContain("[usage]"); + expect(output).toContain("tokens in=1 out=8 cache_read=25k cache_write=7.1k"); + expect(output).not.toContain("\"input_tokens\""); + expect(output).not.toContain("\"tool_use\""); + }); + + it("summarizes result payloads instead of showing raw JSON blobs", () => { + const step = makeStep(); + step.logs.stdout.push(JSON.stringify({ + type: "result", + result: "Completed initial scan", + usage: { + input_tokens: 120, + output_tokens: 40, + }, + })); + + const output = renderLogsTab(step, 100, 10); + expect(output).toContain("[result]"); + expect(output).toContain("Completed initial scan"); + expect(output).toContain("[usage]"); + expect(output).toContain("tokens in=120 out=40"); + }); + + it("summarizes nested opencode/openai tool events into readable lines", () => { + const step = makeStep(); + step.logs.stdout.push(JSON.stringify({ + type: "tool_use", + timestamp: 1773009623735, + part: { + type: "tool", + tool: "bash", + state: { + status: "completed", + input: { + command: "go run ./cmd/apthctl cluster create --clusterclass hcp-aws --dry-run", + description: "Probe hcp-aws charts-dir support", + }, + output: "error: --charts-dir is not supported\nhint: supported flags for hcp-aws\nexit status 4\n", + metadata: { + exit: 1, + description: "Probe hcp-aws charts-dir support", + }, + time: { + start: 1773009636729, + end: 1773009636875, + }, + }, + }, + })); + step.logs.stdout.push(JSON.stringify({ + type: "step_finish", + timestamp: 1773009636883, + part: { + type: "step-finish", + reason: "tool-calls", + cost: 0.060721, + tokens: { + total: 151559, + input: 2728, + output: 607, + reasoning: 516, + cache: { + read: 148224, + write: 0, + }, + }, + }, + })); + + const output = renderLogsTab(step, 180, 20); + expect(output).toContain("[tool call]"); + expect(output).toContain("Bash: Probe hcp-aws charts-dir support | go run ./cmd/apthctl cluster create --clusterclass hcp-aws --dry-run"); + expect(output).toContain("[result]"); + expect(output).toContain("Bash completed exit=1 146ms | error: --charts-dir is not supported"); + expect(output).toContain("[turn]"); + expect(output).toContain("llm turn finished: tool-calls"); + expect(output).toContain("[usage]"); + expect(output).toContain("cost=$0.0607 tok=152k in=2.7k out=607 rsn=516 cache_read=148k cache_write=0"); + expect(output).not.toContain("\"sessionID\""); + expect(output).not.toContain("\"callID\""); + }); + + it("labels roboppi-generated truncation notes explicitly", () => { + const step = makeStep(); + step.logs.progress.push("(logs truncated)"); + + const output = renderLogsTab(step, 100, 10); + expect(output).toContain("[roboppi note]"); + expect(output).toContain("roboppi truncated worker logs"); + }); +}); diff --git a/tests/unit/tui/overview-tab.test.ts b/tests/unit/tui/overview-tab.test.ts new file mode 100644 index 0000000..e4e6768 --- /dev/null +++ b/tests/unit/tui/overview-tab.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "bun:test"; + +import { renderOverviewTab } from "../../../src/tui/components/tabs/overview-tab.js"; +import { RingBuffer } from "../../../src/tui/ring-buffer.js"; +import type { StepUiState, WorkflowUiState } from "../../../src/tui/state-store.js"; + +function makeStep(stepId: string, overrides?: Partial): StepUiState { + const logOpts = { maxLines: 100, maxBytes: 10000 }; + return { + stepId, + status: "RUNNING", + iteration: 1, + maxIterations: 3, + logs: { + stdout: new RingBuffer(logOpts), + stderr: new RingBuffer(logOpts), + progress: new RingBuffer(logOpts), + }, + patches: { + byId: new Map(), + order: [], + byFilePath: new Map(), + }, + ...overrides, + }; +} + +function makeState(step: StepUiState): WorkflowUiState { + const logOpts = { maxLines: 100, maxBytes: 10000 }; + return { + steps: new Map([[step.stepId, step]]), + stepOrder: [step.stepId], + followMode: "selected", + selectedTab: "overview", + coreLogs: new RingBuffer(logOpts), + warnings: new RingBuffer(logOpts), + agentActivity: new RingBuffer(logOpts), + agentEntries: [], + agentRoster: new Map(), + agentRosterOrder: [], + agentRuntime: new Map(), + workflowStatusSummary: undefined, + chatMessages: [], + chatInputActive: false, + chatInputBuffer: "", + chatInputTarget: "", + }; +} + +describe("renderOverviewTab", () => { + it("renders workflow status summary when present", () => { + const step = makeStep("implement", { progress: { ts: 1000, message: "editing", percent: 40 } }); + const state = makeState(step); + state.workflowStatusSummary = { + version: "1", + updated_at: Date.parse("2026-03-09T01:02:03.000Z"), + owner_member_id: "planner", + summary: "Implementer is fixing the cluster bootstrap blocker.", + blockers: ["Manual verification evidence is still missing."], + next_actions: ["Wait for implementer patch", "Rerun tester after patch lands"], + }; + + const output = renderOverviewTab(state, step, 100, 30); + + expect(output).toContain("Summary"); + expect(output).toContain("Implementer is fixing the cluster bootstrap blocker."); + expect(output).toContain("2026-03-09 01:02:03 by planner"); + expect(output).toContain("Blockers:"); + expect(output).toContain("Manual verification evidence is still missing."); + expect(output).toContain("Next:"); + expect(output).toContain("Wait for implementer patch"); + }); + + it("renders placeholder when no workflow status summary exists", () => { + const step = makeStep("implement"); + const state = makeState(step); + + const output = renderOverviewTab(state, step, 80, 20); + + expect(output).toContain("Summary"); + expect(output).toContain("No agent summary yet"); + }); +}); diff --git a/tests/unit/tui/step-list.test.ts b/tests/unit/tui/step-list.test.ts index 602dfa1..b7909a3 100644 --- a/tests/unit/tui/step-list.test.ts +++ b/tests/unit/tui/step-list.test.ts @@ -38,6 +38,9 @@ function makeState(stepIds: string[]): WorkflowUiState { warnings: new RingBuffer(logOpts), agentActivity: new RingBuffer(logOpts), agentEntries: [], + agentRoster: new Map(), + agentRosterOrder: [], + agentRuntime: new Map(), chatMessages: [], chatInputActive: false, chatInputBuffer: "", From ea9ac82945497f7249ec2fef8199ab7609b04968 Mon Sep 17 00:00:00 2001 From: reoring Date: Mon, 9 Mar 2026 08:54:12 +0900 Subject: [PATCH 2/6] fix(agents): unblock dependent tasks after supersede --- src/agents/task-store.ts | 105 ++++++++++++++++++++++----- tests/unit/agents/task-store.test.ts | 64 ++++++++++++++++ 2 files changed, 151 insertions(+), 18 deletions(-) diff --git a/src/agents/task-store.ts b/src/agents/task-store.ts index 7bf6902..a5bb014 100644 --- a/src/agents/task-store.ts +++ b/src/agents/task-store.ts @@ -147,31 +147,96 @@ export function isTaskClaimableBy( export function areTaskDependenciesSatisfied( task: Pick, - completedTaskIds: ReadonlySet, + taskStates: ReadonlyMap>, ): boolean { - return task.depends_on.every((dep) => completedTaskIds.has(dep)); + return task.depends_on.every((dep) => isDependencyResolved(dep, taskStates)); } -async function readCompletedTaskIds(contextDir: string): Promise> { - const completedDir = tasksStatusDir(contextDir, "completed"); - let completedEntries: string[]; - try { - completedEntries = await readdir(completedDir); - } catch { - completedEntries = []; - } +function isDependencyResolved( + taskId: string, + taskStates: ReadonlyMap>, + visiting: Set = new Set(), +): boolean { + const state = taskStates.get(taskId); + if (!state) return false; + if (state.status === "completed") return true; + if (state.status !== "superseded") return false; + if (!state.replacement_task_id) return true; + if (visiting.has(taskId)) return false; + visiting.add(taskId); + const resolved = isDependencyResolved(state.replacement_task_id, taskStates, visiting); + visiting.delete(taskId); + return resolved; +} - return new Set( - completedEntries - .filter((f) => f.endsWith(".json")) - .map((f) => f.replace(".json", "")), +async function readTaskDependencyStates( + contextDir: string, +): Promise>> { + const tasks = await listTasks(contextDir); + return new Map( + tasks.map((task) => [ + task.task_id, + { + status: task.status, + replacement_task_id: task.replacement_task_id, + }, + ]), ); } +async function unblockSatisfiedBlockedTasks(contextDir: string): Promise { + const blocked = await listTasks(contextDir, "blocked"); + if (blocked.length === 0) return 0; + + const taskStates = await readTaskDependencyStates(contextDir); + const blockedDir = tasksStatusDir(contextDir, "blocked"); + const pendingDir = tasksStatusDir(contextDir, "pending"); + const tmpDir = tasksTmp(contextDir); + await mkdir(pendingDir, { recursive: true }); + + let unblocked = 0; + for (const task of blocked) { + if (!areTaskDependenciesSatisfied(task, taskStates)) continue; + + const filename = `${task.task_id}.json`; + const blockedPath = resolve(blockedDir, filename); + const pendingPath = resolve(pendingDir, filename); + const now = Date.now(); + task.status = "pending"; + task.updated_at = now; + task.claimed_by = null; + task.claimed_at = null; + + try { + await atomicJsonWrite(tmpDir, blockedPath, task); + await rename(blockedPath, pendingPath); + } catch { + continue; + } + + taskStates.set(task.task_id, { + status: "pending", + replacement_task_id: task.replacement_task_id, + }); + + await appendTaskEvent(contextDir, { + ts: now, + type: "task_requeued", + task_id: task.task_id, + title: task.title, + }); + unblocked++; + } + + return unblocked; +} + export async function hasActionableTaskForMember( contextDir: string, memberId: string, ): Promise { + await unblockSatisfiedBlockedTasks(contextDir); + const inProgress = await listTasks(contextDir, "in_progress"); if (inProgress.some((task) => task.claimed_by === memberId)) { return true; @@ -183,9 +248,9 @@ export async function hasActionableTaskForMember( return false; } - const completedTaskIds = await readCompletedTaskIds(contextDir); + const taskStates = await readTaskDependencyStates(contextDir); return claimablePending.some((task) => - areTaskDependenciesSatisfied(task, completedTaskIds), + areTaskDependenciesSatisfied(task, taskStates), ); } @@ -249,8 +314,8 @@ export async function claimTask( // Check dependencies if (task.depends_on.length > 0) { - const completedIds = await readCompletedTaskIds(contextDir); - const unmet = task.depends_on.filter((dep) => !completedIds.has(dep)); + const taskStates = await readTaskDependencyStates(contextDir); + const unmet = task.depends_on.filter((dep) => !isDependencyResolved(dep, taskStates)); if (unmet.length > 0) { // Move to blocked/ const blockedPath = resolve(blockedDir, filename); @@ -394,6 +459,8 @@ export async function completeTask( by: memberId, }); + await unblockSatisfiedBlockedTasks(contextDir); + return { ok: true }; } @@ -467,5 +534,7 @@ export async function supersedeTask( replacement_task_id: replacementTaskId, }); + await unblockSatisfiedBlockedTasks(contextDir); + return { ok: true }; } diff --git a/tests/unit/agents/task-store.test.ts b/tests/unit/agents/task-store.test.ts index eae1095..64a4298 100644 --- a/tests/unit/agents/task-store.test.ts +++ b/tests/unit/agents/task-store.test.ts @@ -18,6 +18,7 @@ import { listTasks, claimTask, completeTask, + hasActionableTaskForMember, supersedeTask, validateArtifactPath, } from "../../../src/agents/task-store.js"; @@ -169,6 +170,42 @@ describe("claimTask", () => { expect(result.ok).toBe(true); }); + it("allows claim when dependencies are superseded without replacement", async () => { + const { taskId: dep } = await addTask({ contextDir, title: "Dependency", description: "" }); + await supersedeTask(contextDir, dep, "lead", "no longer needed"); + + const { taskId } = await addTask({ + contextDir, + title: "Dependent Task", + description: "", + dependsOn: [dep], + }); + + const result = await claimTask(contextDir, taskId, "lead"); + expect(result.ok).toBe(true); + }); + + it("keeps dependencies unmet when superseded task has unresolved replacement", async () => { + const { taskId: dep } = await addTask({ contextDir, title: "Dependency", description: "" }); + const { taskId: replacement } = await addTask({ + contextDir, + title: "Replacement", + description: "", + }); + await supersedeTask(contextDir, dep, "lead", "moved", replacement); + + const { taskId } = await addTask({ + contextDir, + title: "Dependent Task", + description: "", + dependsOn: [dep], + }); + + const result = await claimTask(contextDir, taskId, "lead"); + expect(result.ok).toBe(false); + expect(result.error).toContain("Unmet dependencies"); + }); + it("emits task_claimed event", async () => { const { taskId } = await addTask({ contextDir, title: "Claim Event", description: "" }); await claimTask(contextDir, taskId, "alice"); @@ -342,4 +379,31 @@ describe("supersedeTask", () => { expect(superseded.reason).toBe("duplicate"); expect(superseded.replacement_task_id).toBe("replacement-task"); }); + + it("requeues blocked dependents when a dependency is superseded terminally", async () => { + const { taskId: dep } = await addTask({ + contextDir, + title: "Dependency", + description: "", + assignedTo: "lead", + }); + const { taskId } = await addTask({ + contextDir, + title: "Dependent Task", + description: "", + dependsOn: [dep], + assignedTo: "alice", + }); + + const blockedClaim = await claimTask(contextDir, taskId, "alice"); + expect(blockedClaim.ok).toBe(false); + expect(await hasActionableTaskForMember(contextDir, "alice")).toBe(false); + + const superseded = await supersedeTask(contextDir, dep, "lead", "obsolete"); + expect(superseded.ok).toBe(true); + + const pendingEntries = await readdir(tasksStatusDir(contextDir, "pending")); + expect(pendingEntries).toContain(`${taskId}.json`); + expect(await hasActionableTaskForMember(contextDir, "alice")).toBe(true); + }); }); From f21313d68420b954e23493c59a3151a692613787 Mon Sep 17 00:00:00 2001 From: reoring Date: Mon, 9 Mar 2026 09:03:12 +0900 Subject: [PATCH 3/6] docs(examples): add kubernetes operator dev loop --- examples/README.ja.md | 1 + examples/README.md | 1 + .../kubernetes-operator-dev-loop/README.md | 66 +++++ .../kubernetes-operator-dev-loop/agents.yaml | 202 +++++++++++++ .../workflow.yaml | 268 ++++++++++++++++++ 5 files changed, 538 insertions(+) create mode 100644 examples/kubernetes-operator-dev-loop/README.md create mode 100644 examples/kubernetes-operator-dev-loop/agents.yaml create mode 100644 examples/kubernetes-operator-dev-loop/workflow.yaml diff --git a/examples/README.ja.md b/examples/README.ja.md index f3a46ed..ceb47a9 100644 --- a/examples/README.ja.md +++ b/examples/README.ja.md @@ -42,6 +42,7 @@ timeout 10s ./roboppi daemon examples/daemon/simple-cron.yaml --verbose - `examples/todo-loop.yaml`: 反復ループの最小パターン - `examples/agent-pr-loop.yaml`: 大きめのエージェントループ(デモは `examples/agent-pr-loop-demo/`) - `examples/appthrust-dashboard/workflow.yaml`: チームプロジェクト向けの本番ワークフロー(design -> todo -> implement -> validate) +- `examples/kubernetes-operator-dev-loop/workflow.yaml`: Kubernetes operator / controller 開発向けの汎用的な長期 agents ループ ## daemon の例 diff --git a/examples/README.md b/examples/README.md index 2e17640..2e79302 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,6 +43,7 @@ timeout 10s ./roboppi daemon examples/daemon/simple-cron.yaml --verbose - `examples/subworkflow-loop.yaml`: subworkflow step + completion_check loop (see `examples/subworkflow-loop-child.yaml`) - `examples/agent-pr-loop.yaml`: larger multi-step agent loop (see demo under `examples/agent-pr-loop-demo/`) - `examples/appthrust-dashboard/workflow.yaml`: production workflow for a team project (design -> todo -> implement -> validate) +- `examples/kubernetes-operator-dev-loop/workflow.yaml`: generic long-running agents loop for Kubernetes operator / controller development ## Daemon examples diff --git a/examples/kubernetes-operator-dev-loop/README.md b/examples/kubernetes-operator-dev-loop/README.md new file mode 100644 index 0000000..d98bba0 --- /dev/null +++ b/examples/kubernetes-operator-dev-loop/README.md @@ -0,0 +1,66 @@ +# Kubernetes Operator Dev Loop + +This example is a generic long-running agents workflow for developing a +Kubernetes operator or controller-based platform component. + +It is derived from a real multi-agent development loop, but the repo-specific +names and assumptions were removed so it can serve as a reusable example. + +## What It Demonstrates + +- A lead-driven implement -> review -> verify loop +- Planner-owned living TODO, memory, issue index, and rerun contract state +- Dormant specialist activation for: + - manual verification + - long-running E2E + - Kubernetes debugging + - spec clarification + - provenance / rerun gatekeeping + - loop and stall recovery +- Completion checks that look at TODO state, blockers, and verification results + +## Intended Workspace + +Run it against a repository that already has some combination of: + +- `request.md` +- `implementation-plan.md` or an equivalent repo-local design/plan document +- `ARCHITECTURE.md` or `docs/**` +- controller / API / chart / test code + +Typical fit: + +- an operator repo with CRDs and controllers +- a platform repo with Kubernetes reconciliation logic +- a repo where local fast gates and separate cluster-backed validation both exist + +## Run + +```bash +cd /home/reoring/project/roboppi +bun run src/workflow/run.ts \ + examples/kubernetes-operator-dev-loop/workflow.yaml \ + --workspace /path/to/your/operator-repo \ + --verbose +``` + +Or with the built binary: + +```bash +cd /home/reoring/project/roboppi +./roboppi workflow \ + examples/kubernetes-operator-dev-loop/workflow.yaml \ + --workspace /path/to/your/operator-repo \ + --verbose +``` + +## Notes + +- The workflow loads the colocated `agents.yaml` automatically. +- `context_dir` is relative to the target workspace, so state is written under + `.roboppi-loop/operator-dev/` in that repo. +- You will usually want to adapt: + - the fast-gate commands in `workflow.yaml` + - the repo-local plan filename + - the dormant specialist policies in `agents.yaml` + - the completion-check criteria diff --git a/examples/kubernetes-operator-dev-loop/agents.yaml b/examples/kubernetes-operator-dev-loop/agents.yaml new file mode 100644 index 0000000..63cabc5 --- /dev/null +++ b/examples/kubernetes-operator-dev-loop/agents.yaml @@ -0,0 +1,202 @@ +version: "1" + +agents: + lead_agent: + description: "Lead — orchestrates operator implementation, review, and verification" + worker: CLAUDE_CODE + model: "claude-opus-4-6" + capabilities: [READ, MAILBOX, TASKS] + timeout: "60m" + base_instructions: | + You are the lead for a Kubernetes operator development loop. + Coordinate via tasks and mailbox messages; keep iterations small and evidence-driven. + + Persistent shared context: + - Current run identity: .roboppi-loop/current-run.json and .roboppi-loop/operator-dev/run.json + - Living TODO: .roboppi-loop/operator-dev/todo.md (planner-owned) + - Team memory: .roboppi-loop/operator-dev/memory.md (planner-owned) + - Issue index: .roboppi-loop/operator-dev/issues/index.md (planner-owned summary) + - Canonical issue state: .roboppi-loop/operator-dev/issues//issue.json + - Current rerun contract: .roboppi-loop/operator-dev/current-rerun-contract.json + + Process: + - Read the issue index before routing work. + - When memory, the issue index, a task, or a mailbox handoff points at an active issue, read the relevant canonical issue.json first. + - When the active phase, blocker, or owner changes, make sure planner refreshes the TUI overview via `roboppi agents status set`. + - Do not start a new iteration until TODO sync has updated next actions and blockers. + - Do not let blocker/remediation state live only in mailbox. + - Wake dormant specialists only when there is a concrete blocker or validation phase that requires them. + + Dormant specialist wake protocol: + - `roboppi agents members upsert --member --agent --name --role member` + - `roboppi agents tasks add --title "" --assigned-to --json-stdin` + - When their work is done and there is no immediate follow-up, put them back to `role dormant`. + + scout: + description: "Scout — compares the request, plan, and current codebase" + worker: OPENCODE + model: "openai/gpt-5.4" + variant: xhigh + capabilities: [READ, RUN_COMMANDS, MAILBOX, TASKS] + timeout: "45m" + base_instructions: | + You are the scout. + Find the highest-value spec-to-code gaps for the current operator change. + Focus on API shape, controller behavior, generated assets, charts/RBAC, and tests. + + implementation_planner: + description: "Planner — owns TODO, memory, issue state, and rerun contracts" + worker: CLAUDE_CODE + model: "claude-sonnet-4-6" + capabilities: [READ, EDIT, RUN_COMMANDS, MAILBOX, TASKS] + timeout: "90m" + base_instructions: | + You are the planner. + You own: + - .roboppi-loop/operator-dev/todo.md + - .roboppi-loop/operator-dev/memory.md + - .roboppi-loop/operator-dev/issues/index.md + - canonical issue.json files + - .roboppi-loop/operator-dev/current-rerun-contract.json + + Keep all of them synchronized after each meaningful handoff. + + implementer: + description: "Implementer — changes APIs, controllers, charts, and tests" + worker: CLAUDE_CODE + model: "claude-opus-4-6" + capabilities: [READ, EDIT, RUN_TESTS, RUN_COMMANDS, MAILBOX, TASKS] + timeout: "360m" + max_steps: 400 + base_instructions: | + You are the implementer. + + Read first: + - request.md + - implementation-plan.md (or equivalent repo-local plan) + - .roboppi-loop/operator-dev/todo.md + - .roboppi-loop/operator-dev/memory.md + - .roboppi-loop/operator-dev/issues/index.md + + Rules: + - Keep changes focused and avoid unrelated refactors. + - If the repo has generated CRDs/RBAC/chart manifests, update them only when needed and report exactly what changed. + - Never edit the planner-owned TODO/memory/issue index directly. + - When blocked, report concrete evidence: file paths, failing commands, log paths, or issue ids. + - Prefer the repo's standard tool wrapper for kubectl/helm/kind/go if one exists. + + reviewer: + description: "Reviewer — read-only review of APIs, reconciliation, and risk" + worker: OPENCODE + model: "openai/gpt-5.4" + variant: xhigh + capabilities: [READ, RUN_TESTS, RUN_COMMANDS, MAILBOX, TASKS] + timeout: "60m" + base_instructions: | + You are the reviewer. + Review for behavioral regressions, controller safety, spec mismatch, codegen/chart/RBAC drift, and missing tests. + Send actionable feedback to the implementer and a concise summary to the lead. + + tester: + description: "Tester — runs fast local gates" + worker: CODEX_CLI + model: "gpt-5" + capabilities: [READ, RUN_TESTS, RUN_COMMANDS, MAILBOX, TASKS] + timeout: "90m" + base_instructions: | + You are the tester. + Run the fast local acceptance gates for this repo. + Report PASS/FAIL, the first failing command, and the key error lines. + + publisher: + description: "Publisher — commits and pushes a stable milestone" + worker: CLAUDE_CODE + model: "claude-sonnet-4-6" + capabilities: [READ, EDIT, RUN_COMMANDS, MAILBOX, TASKS] + timeout: "45m" + base_instructions: | + You are the publisher. + Only act when the lead explicitly wakes you for a stable milestone. + Do not publish partial or blocker-state work. + + manual_verifier: + description: "Manual verifier — preserves live cluster state during authoritative verification" + worker: CLAUDE_CODE + model: "claude-sonnet-4-6" + capabilities: [READ, RUN_TESTS, RUN_COMMANDS, MAILBOX, TASKS] + timeout: "180m" + base_instructions: | + You are the manual verifier. + Handle cluster-preserving verification only after the lead wakes you for the current rerun contract. + + e2e_runner: + description: "E2E runner — runs long-running kind or cluster-backed acceptance flows" + worker: CLAUDE_CODE + model: "claude-sonnet-4-6" + capabilities: [READ, RUN_TESTS, RUN_COMMANDS, MAILBOX, TASKS] + timeout: "240m" + base_instructions: | + You are the E2E runner. + Run authoritative long-running validation only when woken by the lead for the active rerun contract. + + kubernetes_debugger: + description: "Kubernetes debugger — classifies infra and cluster-backed blockers" + worker: CLAUDE_CODE + model: "claude-sonnet-4-6" + capabilities: [READ, RUN_COMMANDS, MAILBOX, TASKS] + timeout: "120m" + base_instructions: | + You are the Kubernetes debugger. + Diagnose cluster-backed blockers and separate repo-owned issues from environment/infrastructure issues. + + spec_referee: + description: "Spec referee — resolves ambiguous API and behavior interpretations" + worker: OPENCODE + model: "openai/gpt-5.4" + variant: xhigh + capabilities: [READ, RUN_COMMANDS, MAILBOX, TASKS] + timeout: "45m" + base_instructions: | + You are the spec referee. + Resolve ambiguous spec or API interpretation questions and produce concise decisions with evidence. + + gatekeeper: + description: "Gatekeeper — verifies clean-tree provenance and rerun readiness" + worker: CLAUDE_CODE + model: "claude-sonnet-4-6" + capabilities: [READ, RUN_COMMANDS, MAILBOX, TASKS] + timeout: "90m" + base_instructions: | + You are the gatekeeper. + Verify remediation provenance, clean execution snapshots, and rerun readiness before authoritative validation. + + loop_resolver: + description: "Loop resolver — normalizes duplicated tasks and stale workflow state" + worker: CLAUDE_CODE + model: "claude-sonnet-4-6" + capabilities: [READ, EDIT, RUN_COMMANDS, MAILBOX, TASKS] + timeout: "60m" + base_instructions: | + You are the loop resolver. + Resolve duplicated tasks, stale artifacts, and reused-run state that cause the workflow to circle without progress. + + stall_watcher: + description: "Stall watcher — detects repeated discussion without code movement" + worker: OPENCODE + model: "openai/gpt-5.4" + variant: medium + capabilities: [READ, MAILBOX, TASKS] + timeout: "30m" + base_instructions: | + You are the stall watcher. + Notify the lead when the team is repeating the same discussion without new code, evidence, or decisions. + + completion_checker: + description: "Completion checker — verifies the current operator slice is actually done" + worker: CLAUDE_CODE + model: "claude-haiku-4-5" + capabilities: [READ, RUN_TESTS, RUN_COMMANDS] + timeout: "15m" + base_instructions: | + You are the completion checker. + Verify whether the current operator-development slice is complete and write the decision JSON to the requested path. diff --git a/examples/kubernetes-operator-dev-loop/workflow.yaml b/examples/kubernetes-operator-dev-loop/workflow.yaml new file mode 100644 index 0000000..ada97d5 --- /dev/null +++ b/examples/kubernetes-operator-dev-loop/workflow.yaml @@ -0,0 +1,268 @@ +name: kubernetes-operator-dev-loop +version: "1" +timeout: "720m" +concurrency: 3 + +# Store workflow state under .roboppi-loop (gitignored by the target repo). +context_dir: ".roboppi-loop/operator-dev/context" + +# Persistent shared context: +# - Current run pointer: .roboppi-loop/current-run.json +# - Run alias: .roboppi-loop/operator-dev/run.json +# - Living TODO: .roboppi-loop/operator-dev/todo.md +# - Team memory: .roboppi-loop/operator-dev/memory.md +# - Issue index: .roboppi-loop/operator-dev/issues/index.md +# - Canonical issue: .roboppi-loop/operator-dev/issues//issue.json +# - Rerun contract: .roboppi-loop/operator-dev/current-rerun-contract.json + +# Notes: +# - This workflow expects `request.md` to exist at the workspace root. +# - It also expects a repo-local implementation plan such as +# `implementation-plan.md` or equivalent design docs. +# - The agent catalog is loaded from the colocated `agents.yaml`. + +agents: + enabled: true + team_name: "kubernetes-operator-dev" + members: + lead: + agent: lead_agent + scout: + agent: scout + planner: + agent: implementation_planner + implementer: + agent: implementer + reviewer: + agent: reviewer + tester: + agent: tester + publisher: + agent: publisher + role: dormant + manual_verifier: + agent: manual_verifier + role: dormant + e2e_runner: + agent: e2e_runner + role: dormant + kubernetes_debugger: + agent: kubernetes_debugger + role: dormant + spec_referee: + agent: spec_referee + role: dormant + gatekeeper: + agent: gatekeeper + role: dormant + loop_resolver: + agent: loop_resolver + role: dormant + stall_watcher: + agent: stall_watcher + role: dormant + + tasks: + - id: scout-gap-scan + title: "Spec-to-code gap scan" + description: | + Read: + - request.md + - implementation-plan.md (or the repo's source-of-truth implementation plan) + - ARCHITECTURE.md / docs/** for controller design + + Do: + - Scan the current codebase and identify the top missing or incorrect parts. + - Focus on CRDs, reconciliation logic, admission/webhook behavior, chart wiring, + RBAC/codegen, and acceptance coverage. + - Call out the highest-value files and commands for the next iteration. + + Output: + - Send the lead a concise gap list with file paths and likely verification commands. + assigned_to: scout + + - id: planner-initial-plan + title: "Create implementation plan + living TODO" + description: | + Create a concrete, ordered plan and living TODO list for this operator work. + + Read first: + - .roboppi-loop/current-run.json + - .roboppi-loop/operator-dev/run.json + - .roboppi-loop/operator-dev/memory.md (create if missing) + - .roboppi-loop/operator-dev/todo.md (preserve existing checkmarks if present) + - .roboppi-loop/operator-dev/issues/index.md (create if missing) + + Read: + - request.md + - implementation-plan.md (or equivalent repo-local plan) + - ARCHITECTURE.md / docs/** + - AGENTS.md + + Output: + - Write or update `.roboppi-loop/operator-dev/todo.md` + - Refresh `.roboppi-loop/operator-dev/memory.md` + - Record active blockers/remediation items in `.roboppi-loop/operator-dev/issues/` + + TODO requirements: + - Ordered and file-oriented when possible + - Include CRD/codegen/RBAC/chart gates when relevant + - Separate fast local gates from cluster-backed verification + assigned_to: planner + + - id: implement-main + title: "Implement operator changes end-to-end" + description: | + Implement the current plan end-to-end. + + Read: + - request.md + - implementation-plan.md + - .roboppi-loop/operator-dev/todo.md + - .roboppi-loop/operator-dev/memory.md + - ARCHITECTURE.md / docs/** + - AGENTS.md + + Deliverables (high level): + - CRDs/types/controller logic/charts/RBAC updated as required + - Fast validation/tests updated + - No secret leakage + - Minimal, targeted changes + + When ready for review, message reviewer + lead with: + - what changed + - where the risky parts are + - which fast gates to run + assigned_to: implementer + depends_on: + - scout-gap-scan + - planner-initial-plan + + - id: review-main + title: "Review implementation" + description: | + Review the implementation against request.md and the implementation plan. + + Checklist: + - API / CRD contract matches the spec + - Reconciliation logic is coherent and safe + - Generated artifacts / RBAC / chart changes are consistent + - Required tests and commands were updated + - No destructive behavior by default + + Send actionable feedback to implementer; concise summary to lead. + assigned_to: reviewer + depends_on: + - implement-main + + - id: test-fast-gates + title: "Run fast acceptance gates" + description: | + Run the fast acceptance commands for this operator repo and report results. + + Minimum categories: + - build target (for example: make build / make ) + - unit/integration tests (for example: go test ./...) + - codegen / generated-file checks when relevant + - chart / manifest consistency checks when relevant + + Notes: + - Cluster-backed E2E runs are handled by the dedicated e2e_runner. + - If kubectl/helm/kind are missing, prefer the repo's tool wrapper + (for example devbox, mise, asdf, nix, or make targets). + + Output: + - Send PASS/FAIL to implementer + lead. + - If FAIL: include the first failing command and key error lines. + assigned_to: tester + depends_on: + - implement-main + + - id: todo-sync-initial + title: "TODO sync (living checklist + memory)" + description: | + Keep the TODO, memory, issue index, and rerun contract in sync after each loop. + + Inputs: + - .roboppi-loop/current-run.json + - .roboppi-loop/operator-dev/run.json + - .roboppi-loop/operator-dev/todo.md + - .roboppi-loop/operator-dev/memory.md + - .roboppi-loop/operator-dev/issues/index.md + - .roboppi-loop/operator-dev/current-rerun-contract.json + - Latest messages from implementer/reviewer/tester/manual_verifier/e2e_runner/kubernetes_debugger/gatekeeper + + Do: + - Update the living TODO in-place; preserve completed checkmarks. + - Create or refresh canonical issue state for blocker/remediation/stall items. + - Keep the latest rerun contract aligned with the active blocker and authoritative verification target. + - Refresh memory.md with current focus, next actions, and active issue ids/paths. + - If cluster-backed work is waiting on fresh preflight evidence, tell the lead whether to wake + `manual_verifier`, `e2e_runner`, or `kubernetes_debugger`, and which stale tasks to supersede. + + Output: + - Message lead + implementer with updated next actions and blockers. + assigned_to: planner + depends_on: + - review-main + - test-fast-gates + +steps: + orchestrate: + description: "Lead orchestrates the operator implement -> review -> verify loop" + agent: lead_agent + instructions: | + Orchestrate the implement -> review -> verify loop. + + Persistent context: + - .roboppi-loop/current-run.json is the startup pointer for the active run. + - .roboppi-loop/operator-dev/run.json is the authoritative current-run alias. + - .roboppi-loop/operator-dev/todo.md is the living checklist (planner-owned). + - .roboppi-loop/operator-dev/memory.md is the team memory (planner-owned). + - .roboppi-loop/operator-dev/issues/index.md and canonical issue.json files are the source of truth for blockers/remediation. + - .roboppi-loop/operator-dev/current-rerun-contract.json is the latest machine pointer for cluster-backed reruns. + + Loop hygiene: + - This workflow seeds only the core loop tasks. Specialist work is created on demand by the lead. + - Trigger "TODO sync (living checklist + memory)" after each meaningful implementation/review/test result. + - Do not route blocker/remediation work from mailbox-only state once canonical issue state exists. + - Do not start a new implementation iteration until TODO sync has updated next actions and blockers. + - Wake dormant specialists only when there is a concrete blocker or gate that requires them. + - After a coherent slice is complete and fast gates are green, wake `publisher` only if a stable milestone should be committed. + + Use specialists as follows: + - `manual_verifier`: cluster-preserving verification before authoritative reruns + - `e2e_runner`: long-running kind/k8s acceptance flows + - `kubernetes_debugger`: infrastructure or cluster-backed blocker classification + - `spec_referee`: ambiguous API/spec interpretation + - `gatekeeper`: clean-tree / provenance verification before reruns + - `loop_resolver`: duplicated tasks, stale artifacts, or reused run state + - `stall_watcher`: repeated discussion without code movement + + completion_check: + agent: completion_checker + decision_file: ".roboppi-loop/operator-dev/decision.json" + instructions: | + Determine whether this operator-development loop is complete. + + Read: + - request.md + - implementation-plan.md (or equivalent repo-local plan) + - .roboppi-loop/operator-dev/todo.md + - .roboppi-loop/operator-dev/memory.md + - .roboppi-loop/operator-dev/issues/index.md + - .roboppi-loop/operator-dev/current-rerun-contract.json if it exists + + Complete only when: + 1. The requested implementation slice is reflected in code/config/tests. + 2. The living TODO has no unresolved must-do items for the current slice. + 3. Fast acceptance gates are green or explicitly waived with written rationale. + 4. Any active blocker/remediation issue is either resolved or clearly out of scope. + 5. If cluster-backed verification was required, the latest authoritative result is recorded. + + Write the decision JSON to `.roboppi-loop/operator-dev/decision.json`. + capabilities: [READ, RUN_TESTS, RUN_COMMANDS] + timeout: "15m" + + max_iterations: 12 + on_iterations_exhausted: abort From 2c9dd7d20980a5ad3e774c68604b0a40e9b4a57a Mon Sep 17 00:00:00 2001 From: reoring Date: Mon, 9 Mar 2026 12:44:00 +0900 Subject: [PATCH 4/6] feat(agents): add mcp tooling and runtime visibility --- docs/features/agents.md | 11 +- docs/spec/agents.md | 2 +- src/agents/cli.ts | 7 + src/agents/mcp.ts | 731 ++++++++++++++++++ src/agents/resident-agent.ts | 27 +- src/tui/components/tabs/agent-overview-tab.ts | 14 + src/tui/components/tabs/logs-tab.ts | 153 ++++ src/tui/exec-event.ts | 5 + src/tui/state-store.ts | 58 ++ src/types/index.ts | 1 + src/types/mcp-server.ts | 22 + src/worker/adapters/base-process-adapter.ts | 8 +- src/worker/adapters/codex-cli-adapter.ts | 50 ++ src/worker/adapters/opencode-adapter.ts | 60 ++ src/workflow/agent-catalog.ts | 65 ++ src/workflow/executor.ts | 49 ++ test/unit/agents/resident-agent.test.ts | 121 +++ test/unit/tui/state-store.test.ts | 54 +- test/unit/worker/codex-cli-adapter.test.ts | 34 + test/unit/worker/opencode-adapter.test.ts | 65 +- test/unit/workflow/agent-catalog.test.ts | 69 ++ tests/at/agents-mcp.test.ts | 348 +++++++++ tests/unit/tui/agent-overview-tab.test.ts | 37 + tests/unit/tui/logs-tab.test.ts | 44 ++ 24 files changed, 2020 insertions(+), 15 deletions(-) create mode 100644 src/agents/mcp.ts create mode 100644 src/types/mcp-server.ts create mode 100644 tests/at/agents-mcp.test.ts diff --git a/docs/features/agents.md b/docs/features/agents.md index 72cf6a9..86604d2 100644 --- a/docs/features/agents.md +++ b/docs/features/agents.md @@ -490,10 +490,10 @@ Implementation note: - Prefer `roboppi agents message ...` subcommands that accept structured JSON via stdin (`--json-stdin`) to avoid quoting errors. -### 6.3 Optional: MCP server (future) +### 6.3 MCP server -For better ergonomics (no shell quoting, richer schemas), provide an MCP server -`roboppi agents mcp` that exposes tools: +For better ergonomics (no shell quoting, richer schemas), `roboppi agents mcp` +exposes a stdio MCP server with tools: - `agents_send_message` - `agents_broadcast` @@ -503,6 +503,11 @@ For better ergonomics (no shell quoting, richer schemas), provide an MCP server - `agents_tasks_list` - `agents_tasks_claim` - `agents_tasks_complete` +- `agents_tasks_supersede` +- `agents_status_get` +- `agents_status_set` +- `agents_specialist_activate` +- `agents_specialist_deactivate` Teammate sessions (Claude Code) already load MCP servers as part of context. This aligns with the Claude agent teams note that teammates "load the same diff --git a/docs/spec/agents.md b/docs/spec/agents.md index 7ad3afc..cfd550e 100644 --- a/docs/spec/agents.md +++ b/docs/spec/agents.md @@ -24,7 +24,7 @@ Out of scope for this document: - multi-host agents support - nested agentss -- MCP transport delivery (`roboppi agents mcp`) in v1 +- broader MCP coverage beyond the current `roboppi agents mcp` surface ## 1. Source Of Truth diff --git a/src/agents/cli.ts b/src/agents/cli.ts index 369da4d..b4da3d5 100644 --- a/src/agents/cli.ts +++ b/src/agents/cli.ts @@ -857,6 +857,7 @@ USAGE roboppi agents tasks supersede --context --task-id --member [--reason ] [--replacement-task-id ] roboppi agents status get --context roboppi agents status set --context --summary [--owner ] [--blocker ]... [--next ]... [--json-stdin|--clear] + roboppi agents mcp [--context ] roboppi agents chat --context [--as ] [--poll-ms ] roboppi agents housekeep --context @@ -959,6 +960,12 @@ export async function runAgentsCli(argv: string[]): Promise { } break; + case "mcp": { + const { runAgentsMcp } = await import("./mcp.js"); + await runAgentsMcp(rest); + break; + } + case "chat": await handleChat(rest); break; diff --git a/src/agents/mcp.ts b/src/agents/mcp.ts new file mode 100644 index 0000000..cf3ad09 --- /dev/null +++ b/src/agents/mcp.ts @@ -0,0 +1,731 @@ +import { readTeam, readMembers, validateMember, deliverMessage, broadcastMessage, recvMessages, ackMessage, ackMessageByClaimToken, upsertMember } from "./store.js"; +import { addTask, claimTask, completeTask, listTasks, supersedeTask } from "./task-store.js"; +import { readWorkflowStatus, writeWorkflowStatus, clearWorkflowStatus } from "./status-store.js"; +import { assertAgentsRootSafe, validateIdPath, validateMemberIdPath } from "./path-safety.js"; +import type { MemberEntry, MessageKind, TaskStatus } from "./types.js"; + +const MCP_PROTOCOL_VERSION = "2024-11-05"; +const SERVER_NAME = "roboppi-agents-mcp"; +const SERVER_VERSION = "0.1.0"; + +type Json = + | null + | boolean + | number + | string + | Json[] + | { [key: string]: Json }; + +interface JsonRpcRequest { + jsonrpc: "2.0"; + id?: Json; + method: string; + params?: Record; +} + +interface JsonRpcResponse { + jsonrpc: "2.0"; + id: Json; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +} + +interface McpTool { + name: string; + description: string; + inputSchema: Record; + handler: (args: Record, env: ServerEnv) => Promise; +} + +interface ServerEnv { + defaultContextDir?: string; + callerMemberId?: string; +} + +class JsonRpcError extends Error { + constructor( + readonly code: number, + message: string, + readonly data?: unknown, + ) { + super(message); + this.name = "JsonRpcError"; + } +} + +function assertObject(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new JsonRpcError(-32602, `${label} must be an object`); + } + return value as Record; +} + +function getOptionalString(obj: Record, key: string): string | undefined { + const value = obj[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") { + throw new JsonRpcError(-32602, `"${key}" must be a string`); + } + return value; +} + +function getRequiredString(obj: Record, key: string): string { + const value = getOptionalString(obj, key); + if (!value) { + throw new JsonRpcError(-32602, `"${key}" is required`); + } + return value; +} + +function getOptionalBoolean(obj: Record, key: string): boolean | undefined { + const value = obj[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== "boolean") { + throw new JsonRpcError(-32602, `"${key}" must be a boolean`); + } + return value; +} + +function getOptionalNumber(obj: Record, key: string): number | undefined { + const value = obj[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new JsonRpcError(-32602, `"${key}" must be a finite number`); + } + return value; +} + +function getOptionalStringArray(obj: Record, key: string): string[] | undefined { + const value = obj[key]; + if (value === undefined || value === null) return undefined; + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { + throw new JsonRpcError(-32602, `"${key}" must be an array of strings`); + } + return value as string[]; +} + +function getOptionalRecord(obj: Record, key: string): Record | undefined { + const value = obj[key]; + if (value === undefined || value === null) return undefined; + return assertObject(value, key); +} + +function resolveContextDir(args: Record, env: ServerEnv): string { + const value = getOptionalString(args, "context_dir") ?? env.defaultContextDir ?? process.env.ROBOPPI_AGENTS_CONTEXT_DIR; + if (!value) { + throw new JsonRpcError(-32602, `"context_dir" is required (or set ROBOPPI_AGENTS_CONTEXT_DIR)`); + } + return value; +} + +function resolveCallerMemberId(args: Record, key: string, env: ServerEnv): string { + const value = getOptionalString(args, key) ?? env.callerMemberId ?? process.env.ROBOPPI_AGENTS_MEMBER_ID; + if (!value) { + throw new JsonRpcError(-32602, `"${key}" is required (or set ROBOPPI_AGENTS_MEMBER_ID)`); + } + return value; +} + +function memberName(entry: MemberEntry): string { + return entry.name || entry.member_id; +} + +async function requireLeadIdentity(contextDir: string, env: ServerEnv): Promise { + const team = await readTeam(contextDir); + const callerMemberId = env.callerMemberId ?? process.env.ROBOPPI_AGENTS_MEMBER_ID; + if (!callerMemberId) { + throw new JsonRpcError(-32602, "ROBOPPI_AGENTS_MEMBER_ID is not set; this tool requires lead identity"); + } + if (team.lead_member_id !== callerMemberId) { + throw new JsonRpcError(-32602, `Only the lead ("${team.lead_member_id}") may use this tool. Caller: "${callerMemberId}"`); + } + return callerMemberId; +} + +function renderToolResult(result: unknown): { content: Array<{ type: "text"; text: string }>; structuredContent: unknown; isError?: boolean } { + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + structuredContent: result, + }; +} + +function renderToolError(error: unknown): { content: Array<{ type: "text"; text: string }>; structuredContent: { ok: false; error: string }; isError: true } { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text", text: message }], + structuredContent: { ok: false, error: message }, + isError: true, + }; +} + +function buildTools(): McpTool[] { + return [ + { + name: "agents_list_members", + description: "List current agent team members from members.json.", + inputSchema: { + type: "object", + properties: { + context_dir: { type: "string" }, + }, + }, + handler: async (args, env) => { + const contextDir = resolveContextDir(args, env); + const team = await readTeam(contextDir); + const { members } = await readMembers(contextDir); + return { + ok: true, + team_id: team.team_id, + team_name: team.name, + lead_member_id: team.lead_member_id, + members, + }; + }, + }, + { + name: "agents_send_message", + description: "Deliver a direct message to a teammate inbox.", + inputSchema: { + type: "object", + properties: { + context_dir: { type: "string" }, + from_member_id: { type: "string" }, + to_member_id: { type: "string" }, + topic: { type: "string" }, + body: { type: "string" }, + kind: { type: "string" }, + correlation_id: { type: "string" }, + reply_to: { type: "string" }, + metadata: { type: "object" }, + }, + required: ["to_member_id", "topic", "body"], + }, + handler: async (args, env) => { + const contextDir = resolveContextDir(args, env); + const team = await readTeam(contextDir); + const fromMemberId = resolveCallerMemberId(args, "from_member_id", env); + const fromEntry = await validateMember(contextDir, fromMemberId); + const toMemberId = getRequiredString(args, "to_member_id"); + validateMemberIdPath(toMemberId); + await validateMember(contextDir, toMemberId); + const result = await deliverMessage({ + contextDir, + teamId: team.team_id, + fromMemberId, + fromName: memberName(fromEntry), + toMemberId, + kind: getOptionalString(args, "kind") as MessageKind | undefined, + topic: getRequiredString(args, "topic"), + body: getRequiredString(args, "body"), + correlationId: getOptionalString(args, "correlation_id"), + replyTo: getOptionalString(args, "reply_to"), + metadata: getOptionalRecord(args, "metadata"), + }); + return { ok: true, message_id: result.messageId }; + }, + }, + { + name: "agents_broadcast", + description: "Broadcast a message to all teammates.", + inputSchema: { + type: "object", + properties: { + context_dir: { type: "string" }, + from_member_id: { type: "string" }, + topic: { type: "string" }, + body: { type: "string" }, + kind: { type: "string" }, + metadata: { type: "object" }, + include_self: { type: "boolean" }, + }, + required: ["topic", "body"], + }, + handler: async (args, env) => { + const contextDir = resolveContextDir(args, env); + const team = await readTeam(contextDir); + const fromMemberId = resolveCallerMemberId(args, "from_member_id", env); + const fromEntry = await validateMember(contextDir, fromMemberId); + const result = await broadcastMessage({ + contextDir, + teamId: team.team_id, + fromMemberId, + fromName: memberName(fromEntry), + topic: getRequiredString(args, "topic"), + body: getRequiredString(args, "body"), + kind: getOptionalString(args, "kind") as MessageKind | undefined, + metadata: getOptionalRecord(args, "metadata"), + includeSelf: getOptionalBoolean(args, "include_self"), + }); + return { ok: true, message_id: result.messageId, delivered: result.delivered }; + }, + }, + { + name: "agents_recv", + description: "Receive inbox messages for a member, optionally claiming them.", + inputSchema: { + type: "object", + properties: { + context_dir: { type: "string" }, + member_id: { type: "string" }, + claim: { type: "boolean" }, + max: { type: "number" }, + }, + }, + handler: async (args, env) => { + const contextDir = resolveContextDir(args, env); + const memberId = resolveCallerMemberId(args, "member_id", env); + validateMemberIdPath(memberId); + await validateMember(contextDir, memberId); + const messages = await recvMessages({ + contextDir, + memberId, + claim: getOptionalBoolean(args, "claim"), + max: getOptionalNumber(args, "max"), + }); + return { ok: true, messages }; + }, + }, + { + name: "agents_ack", + description: "Acknowledge a claimed or delivered message.", + inputSchema: { + type: "object", + properties: { + context_dir: { type: "string" }, + member_id: { type: "string" }, + message_id: { type: "string" }, + claim_token: { type: "string" }, + }, + }, + handler: async (args, env) => { + const contextDir = resolveContextDir(args, env); + const memberId = resolveCallerMemberId(args, "member_id", env); + validateMemberIdPath(memberId); + await validateMember(contextDir, memberId); + const claimToken = getOptionalString(args, "claim_token"); + const messageId = getOptionalString(args, "message_id"); + if (!claimToken && !messageId) { + throw new JsonRpcError(-32602, "\"claim_token\" or \"message_id\" is required"); + } + if (claimToken) { + const ok = await ackMessageByClaimToken(contextDir, memberId, claimToken); + return { ok }; + } + validateIdPath(messageId!, "message ID"); + const ok = await ackMessage(contextDir, memberId, messageId!); + return { ok }; + }, + }, + { + name: "agents_tasks_list", + description: "List agent tasks, optionally filtered by status.", + inputSchema: { + type: "object", + properties: { + context_dir: { type: "string" }, + status: { type: "string" }, + }, + }, + handler: async (args, env) => { + const contextDir = resolveContextDir(args, env); + const status = getOptionalString(args, "status") as TaskStatus | undefined; + const tasks = await listTasks(contextDir, status); + return { ok: true, tasks }; + }, + }, + { + name: "agents_tasks_claim", + description: "Claim a pending task for a member.", + inputSchema: { + type: "object", + properties: { + context_dir: { type: "string" }, + task_id: { type: "string" }, + member_id: { type: "string" }, + }, + required: ["task_id"], + }, + handler: async (args, env) => { + const contextDir = resolveContextDir(args, env); + const taskId = getRequiredString(args, "task_id"); + const memberId = resolveCallerMemberId(args, "member_id", env); + validateIdPath(taskId, "task ID"); + validateMemberIdPath(memberId); + const result = await claimTask(contextDir, taskId, memberId); + return result; + }, + }, + { + name: "agents_tasks_complete", + description: "Complete an in-progress task.", + inputSchema: { + type: "object", + properties: { + context_dir: { type: "string" }, + task_id: { type: "string" }, + member_id: { type: "string" }, + artifacts: { type: "array", items: { type: "string" } }, + }, + required: ["task_id"], + }, + handler: async (args, env) => { + const contextDir = resolveContextDir(args, env); + const taskId = getRequiredString(args, "task_id"); + const memberId = resolveCallerMemberId(args, "member_id", env); + validateIdPath(taskId, "task ID"); + validateMemberIdPath(memberId); + return completeTask(contextDir, taskId, memberId, getOptionalStringArray(args, "artifacts")); + }, + }, + { + name: "agents_tasks_supersede", + description: "Supersede a pending, blocked, or in-progress task.", + inputSchema: { + type: "object", + properties: { + context_dir: { type: "string" }, + task_id: { type: "string" }, + member_id: { type: "string" }, + reason: { type: "string" }, + replacement_task_id: { type: "string" }, + }, + required: ["task_id"], + }, + handler: async (args, env) => { + const contextDir = resolveContextDir(args, env); + const taskId = getRequiredString(args, "task_id"); + const memberId = resolveCallerMemberId(args, "member_id", env); + validateIdPath(taskId, "task ID"); + validateMemberIdPath(memberId); + const replacementTaskId = getOptionalString(args, "replacement_task_id"); + if (replacementTaskId) { + validateIdPath(replacementTaskId, "replacement task ID"); + } + return supersedeTask( + contextDir, + taskId, + memberId, + getOptionalString(args, "reason"), + replacementTaskId, + ); + }, + }, + { + name: "agents_status_get", + description: "Read the current workflow status summary.", + inputSchema: { + type: "object", + properties: { + context_dir: { type: "string" }, + }, + }, + handler: async (args, env) => { + const contextDir = resolveContextDir(args, env); + return { ok: true, status: await readWorkflowStatus(contextDir) }; + }, + }, + { + name: "agents_status_set", + description: "Set or clear the workflow status summary.", + inputSchema: { + type: "object", + properties: { + context_dir: { type: "string" }, + owner_member_id: { type: "string" }, + summary: { type: "string" }, + blockers: { type: "array", items: { type: "string" } }, + next_actions: { type: "array", items: { type: "string" } }, + clear: { type: "boolean" }, + }, + }, + handler: async (args, env) => { + const contextDir = resolveContextDir(args, env); + if (getOptionalBoolean(args, "clear")) { + await clearWorkflowStatus(contextDir); + return { ok: true, cleared: true }; + } + const ownerMemberId = resolveCallerMemberId(args, "owner_member_id", env); + const status = await writeWorkflowStatus({ + contextDir, + ownerMemberId, + summary: getRequiredString(args, "summary"), + blockers: getOptionalStringArray(args, "blockers"), + nextActions: getOptionalStringArray(args, "next_actions"), + }); + return { ok: true, status }; + }, + }, + { + name: "agents_specialist_activate", + description: "Lead-only helper to wake a dormant specialist and optionally seed a task for it.", + inputSchema: { + type: "object", + properties: { + context_dir: { type: "string" }, + member_id: { type: "string" }, + agent_id: { type: "string" }, + name: { type: "string" }, + role: { type: "string" }, + task: { + type: "object", + properties: { + title: { type: "string" }, + description: { type: "string" }, + depends_on: { type: "array", items: { type: "string" } }, + tags: { type: "array", items: { type: "string" } }, + requires_plan_approval: { type: "boolean" }, + }, + required: ["title", "description"], + }, + }, + required: ["member_id", "agent_id"], + }, + handler: async (args, env) => { + const contextDir = resolveContextDir(args, env); + await assertAgentsRootSafe(contextDir); + await requireLeadIdentity(contextDir, env); + const memberId = getRequiredString(args, "member_id"); + validateMemberIdPath(memberId); + const agentId = getRequiredString(args, "agent_id"); + const role = getOptionalString(args, "role") ?? "member"; + const name = getOptionalString(args, "name") ?? memberId; + await upsertMember(contextDir, { + member_id: memberId, + name, + role, + agent: agentId, + }); + + const task = getOptionalRecord(args, "task"); + let taskId: string | undefined; + if (task) { + const result = await addTask({ + contextDir, + title: getRequiredString(task, "title"), + description: getRequiredString(task, "description"), + assignedTo: memberId, + dependsOn: getOptionalStringArray(task, "depends_on"), + tags: getOptionalStringArray(task, "tags"), + requiresPlanApproval: getOptionalBoolean(task, "requires_plan_approval"), + }); + taskId = result.taskId; + } + + return { ok: true, member_id: memberId, role, task_id: taskId ?? null }; + }, + }, + { + name: "agents_specialist_deactivate", + description: "Lead-only helper to return a specialist to dormant state without deleting it from the roster.", + inputSchema: { + type: "object", + properties: { + context_dir: { type: "string" }, + member_id: { type: "string" }, + role: { type: "string" }, + }, + required: ["member_id"], + }, + handler: async (args, env) => { + const contextDir = resolveContextDir(args, env); + await assertAgentsRootSafe(contextDir); + await requireLeadIdentity(contextDir, env); + const memberId = getRequiredString(args, "member_id"); + validateMemberIdPath(memberId); + const existing = await validateMember(contextDir, memberId); + const role = getOptionalString(args, "role") ?? "dormant"; + await upsertMember(contextDir, { + ...existing, + role, + }); + return { ok: true, member_id: memberId, role }; + }, + }, + ]; +} + +function parseContextArg(argv: string[]): string | undefined { + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--context") { + const value = argv[i + 1]; + if (!value || value.startsWith("-")) { + throw new JsonRpcError(-32602, "--context requires a value"); + } + return value; + } + if (argv[i] === "--help" || argv[i] === "-h") { + process.stderr.write("roboppi agents mcp [--context ]\n"); + process.exit(0); + } + } + return undefined; +} + +async function dispatchRequest( + request: JsonRpcRequest, + tools: Map, + env: ServerEnv, +): Promise { + if (request.jsonrpc !== "2.0" || typeof request.method !== "string") { + throw new JsonRpcError(-32600, "Invalid JSON-RPC request"); + } + + const id = request.id; + const params = request.params ?? {}; + + switch (request.method) { + case "initialize": { + if (id === undefined) return null; + const initParams = assertObject(params, "initialize params"); + const protocolVersion = getOptionalString(initParams, "protocolVersion") ?? MCP_PROTOCOL_VERSION; + return { + jsonrpc: "2.0", + id, + result: { + protocolVersion, + capabilities: { tools: {} }, + serverInfo: { + name: SERVER_NAME, + version: SERVER_VERSION, + }, + }, + }; + } + case "notifications/initialized": + return null; + case "ping": + if (id === undefined) return null; + return { jsonrpc: "2.0", id, result: {} }; + case "tools/list": + if (id === undefined) return null; + return { + jsonrpc: "2.0", + id, + result: { + tools: [...tools.values()].map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }, + }; + case "tools/call": { + if (id === undefined) return null; + const callParams = assertObject(params, "tools/call params"); + const name = getRequiredString(callParams, "name"); + const tool = tools.get(name); + if (!tool) { + throw new JsonRpcError(-32602, `Unknown tool "${name}"`); + } + const args = getOptionalRecord(callParams, "arguments") ?? {}; + try { + const result = await tool.handler(args, env); + return { jsonrpc: "2.0", id, result: renderToolResult(result) }; + } catch (err) { + return { jsonrpc: "2.0", id, result: renderToolError(err) }; + } + } + default: + throw new JsonRpcError(-32601, `Method not found: ${request.method}`); + } +} + +function encodeMessage(message: unknown): Buffer { + const body = Buffer.from(JSON.stringify(message), "utf8"); + const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "utf8"); + return Buffer.concat([header, body]); +} + +async function readLoop( + onMessage: (request: JsonRpcRequest) => Promise, +): Promise { + let buffer = Buffer.alloc(0); + let contentLength: number | null = null; + + const processBuffer = async (): Promise => { + while (true) { + if (contentLength === null) { + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1) break; + const headerText = buffer.subarray(0, headerEnd).toString("utf8"); + buffer = buffer.subarray(headerEnd + 4); + const headers = headerText.split("\r\n"); + const contentLengthHeader = headers.find((line) => line.toLowerCase().startsWith("content-length:")); + if (!contentLengthHeader) { + throw new JsonRpcError(-32600, "Missing Content-Length header"); + } + const rawLength = contentLengthHeader.split(":")[1]?.trim(); + const parsedLength = Number(rawLength); + if (!Number.isFinite(parsedLength) || parsedLength < 0) { + throw new JsonRpcError(-32600, `Invalid Content-Length: ${rawLength ?? ""}`); + } + contentLength = parsedLength; + } + + if (contentLength === null || buffer.length < contentLength) break; + + const payload = buffer.subarray(0, contentLength); + buffer = buffer.subarray(contentLength); + contentLength = null; + + const data = JSON.parse(payload.toString("utf8")) as JsonRpcRequest; + await onMessage(data); + } + }; + + await new Promise((resolve, reject) => { + let chain = Promise.resolve(); + + process.stdin.on("data", (chunk: Buffer | string) => { + const piece = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + buffer = Buffer.concat([buffer, piece]); + chain = chain.then(() => processBuffer()).catch(reject); + }); + + process.stdin.on("end", () => { + chain.then(() => resolve()).catch(reject); + }); + + process.stdin.on("error", reject); + process.stdin.resume(); + }); +} + +export async function runAgentsMcp(argv: string[]): Promise { + const tools = new Map(buildTools().map((tool) => [tool.name, tool])); + const env: ServerEnv = { + defaultContextDir: parseContextArg(argv), + callerMemberId: process.env.ROBOPPI_AGENTS_MEMBER_ID, + }; + + const send = (message: JsonRpcResponse): void => { + process.stdout.write(encodeMessage(message)); + }; + + await readLoop(async (request) => { + try { + const response = await dispatchRequest(request, tools, env); + if (response) send(response); + } catch (err) { + const id = request && typeof request === "object" && "id" in request ? (request as JsonRpcRequest).id ?? null : null; + const error = err instanceof JsonRpcError + ? err + : new JsonRpcError(-32000, err instanceof Error ? err.message : String(err)); + if (id !== undefined) { + send({ + jsonrpc: "2.0", + id, + error: { + code: error.code, + message: error.message, + data: error.data, + }, + }); + } + } + }); +} diff --git a/src/agents/resident-agent.ts b/src/agents/resident-agent.ts index 89c6d9a..a5b12b0 100644 --- a/src/agents/resident-agent.ts +++ b/src/agents/resident-agent.ts @@ -38,6 +38,7 @@ import type { WorkerTask, WorkerResult } from "../types/index.js"; import type { WorkerAdapter } from "../worker/worker-adapter.js"; import type { ExecEventSink } from "../tui/exec-event.js"; import type { AgentProfile } from "../workflow/agent-catalog.js"; +import type { McpServerConfig } from "../types/mcp-server.js"; import type { ReceivedMessage } from "./store.js"; // --------------------------------------------------------------------------- @@ -64,7 +65,26 @@ interface SessionEntry { } function cloneProfileDefaultArgs(profile: AgentProfile): string[] { - return Array.isArray(profile.defaultArgs) ? [...profile.defaultArgs] : []; + const args = Array.isArray(profile.defaultArgs) ? [...profile.defaultArgs] : []; + if (profile.worker === "CLAUDE_CODE" && Array.isArray(profile.mcp_configs)) { + for (const config of profile.mcp_configs) { + args.push("--mcp-config", config); + } + if (profile.strict_mcp_config) { + args.push("--strict-mcp-config"); + } + } + return args; +} + +function cloneProfileMcpServers(profile: AgentProfile): McpServerConfig[] { + return Array.isArray(profile.mcp_servers) + ? profile.mcp_servers.map((server) => ({ + ...server, + ...(server.args ? { args: [...server.args] } : {}), + ...(server.env ? { env: { ...server.env } } : {}), + })) + : []; } export function createAdapterForAgentProfile( @@ -72,12 +92,13 @@ export function createAdapterForAgentProfile( profile: AgentProfile, ): WorkerAdapter { const defaultArgs = cloneProfileDefaultArgs(profile); + const mcpServers = cloneProfileMcpServers(profile); switch (profile.worker) { case "OPENCODE": - return new OpenCodeAdapter(processManager, { defaultArgs }); + return new OpenCodeAdapter(processManager, { defaultArgs, mcpServers }); case "CODEX_CLI": - return new CodexCliAdapter(processManager, { defaultArgs }); + return new CodexCliAdapter(processManager, { defaultArgs, mcpServers }); case "CLAUDE_CODE": default: return new ClaudeCodeAdapter( diff --git a/src/tui/components/tabs/agent-overview-tab.ts b/src/tui/components/tabs/agent-overview-tab.ts index ea69665..87135c8 100644 --- a/src/tui/components/tabs/agent-overview-tab.ts +++ b/src/tui/components/tabs/agent-overview-tab.ts @@ -98,6 +98,15 @@ export function renderAgentOverviewTab( lines.push(`\x1b[1mBusy Time:\x1b[0m ${formatMs(runtime.totalDispatchActiveMs)}`); } + if ((runtime?.mcpAvailable?.length ?? 0) > 0 || (runtime?.observedMcpTools?.length ?? 0) > 0) { + lines.push(`\x1b[1mMCP Ready:\x1b[0m ${formatList(runtime?.mcpAvailable, "none configured")}`); + lines.push(`\x1b[1mMCP Used:\x1b[0m ${formatList(runtime?.observedMcpTools, "none observed")}`); + } + if ((runtime?.skillHints?.length ?? 0) > 0 || (runtime?.observedSkills?.length ?? 0) > 0) { + lines.push(`\x1b[1mSkill Hints:\x1b[0m ${formatList(runtime?.skillHints, "none referenced")}`); + lines.push(`\x1b[1mSkills Read:\x1b[0m ${formatList(runtime?.observedSkills, "none observed")}`); + } + const instructionText = runtime?.currentInstructions ?? runtime?.lastInstructions; if (instructionText) { lines.push(""); @@ -228,6 +237,11 @@ function formatTs(ts: number): string { return d.toISOString().replace("T", " ").slice(0, 19); } +function formatList(values: string[] | undefined, emptyLabel: string): string { + if (!values || values.length === 0) return `\x1b[90m${emptyLabel}\x1b[0m`; + return values.map((value) => sanitizeForTui(value)).join(", "); +} + function appendWrappedBlock( lines: string[], text: string, diff --git a/src/tui/components/tabs/logs-tab.ts b/src/tui/components/tabs/logs-tab.ts index 6a5fb9a..047528c 100644 --- a/src/tui/components/tabs/logs-tab.ts +++ b/src/tui/components/tabs/logs-tab.ts @@ -91,6 +91,8 @@ function summarizeStructuredEvent(parsed: Record): Array<{ chan const entries: Array<{ channel: string; line: string }> = []; const part = isRecord(parsed.part) ? parsed.part : undefined; + entries.push(...summarizeResponseItemLifecycle(parsed)); + if (part) { entries.push(...summarizeNestedPart(parsed, part)); } @@ -123,6 +125,86 @@ function summarizeStructuredEvent(parsed: Record): Array<{ chan return entries; } +function summarizeResponseItemLifecycle( + parsed: Record, +): Array<{ channel: string; line: string }> { + const type = typeof parsed.type === "string" ? parsed.type : ""; + if (type !== "item.started" && type !== "item.completed") { + return []; + } + + const phase = type === "item.started" ? "started" : "completed"; + const item = isRecord(parsed.item) ? parsed.item : undefined; + if (!item) { + return [{ channel: "llm_turn", line: `response item ${phase}` }]; + } + + const itemType = typeof item.type === "string" ? item.type : undefined; + const itemName = typeof item.name === "string" + ? item.name + : (typeof item.role === "string" ? item.role : undefined); + + if (itemType === "command_execution") { + const command = summarizeCommandExecution(item); + if (phase === "started") { + return [{ + channel: "llm_tool_call", + line: command ? `Command started | ${command}` : "Command started", + }]; + } + + const result = summarizeCommandExecutionResult(item); + return [{ + channel: "llm_result", + line: result ?? (command ? `Command completed | ${command}` : "Command completed"), + }]; + } + + if (itemType === "agent_message") { + const text = extractLifecycleDetail(item); + if (text) { + return [{ channel: "llm_message", line: text }]; + } + return [{ channel: "llm_turn", line: `agent message ${phase}` }]; + } + + if (itemType && (itemType.includes("tool") || itemType.includes("function"))) { + const toolName = normalizeToolName(itemName ?? "tool"); + const detail = extractLifecycleDetail(item); + return [{ + channel: phase === "started" ? "llm_tool_call" : "llm_result", + line: detail ? `${toolName} ${phase} | ${detail}` : `${toolName} ${phase}`, + }]; + } + + if (itemType === "reasoning" || itemType === "analysis") { + const detail = extractLifecycleDetail(item); + return [{ + channel: detail ? "llm_thinking" : "llm_turn", + line: detail ? detail : `reasoning ${phase}`, + }]; + } + + const contentItems = getContentItems(item); + if (phase === "completed" && contentItems.length > 0) { + const summarized = contentItems + .map((content) => summarizeContentItem(content)) + .filter((value): value is { channel: string; line: string } => Boolean(value)); + if (summarized.length > 0) { + return summarized; + } + } + + const label = [itemType, itemName].filter(Boolean).join(" "); + const detail = extractLifecycleDetail(item); + return [{ + channel: "llm_turn", + line: detail + ? `${label || "response item"} ${phase} | ${detail}` + : `${label || "response item"} ${phase}`, + }]; +} + function summarizeNestedPart( outer: Record, part: Record, @@ -409,6 +491,77 @@ function normalizeToolName(name: string): string { return name.charAt(0).toUpperCase() + name.slice(1); } +function extractLifecycleDetail(item: Record): string | undefined { + if (typeof item.summary === "string" && item.summary.trim()) { + return truncateInline(item.summary, 180); + } + if (typeof item.text === "string" && item.text.trim()) { + return truncateInline(item.text, 180); + } + if (typeof item.content === "string" && item.content.trim()) { + return truncateInline(item.content, 180); + } + if (typeof item.arguments === "string" && item.arguments.trim()) { + return truncateInline(item.arguments, 180); + } + + const input = isRecord(item.input) ? item.input : undefined; + if (input) { + if (typeof input.description === "string" && input.description.trim()) { + return truncateInline(input.description, 180); + } + if (typeof input.command === "string" && input.command.trim()) { + return truncateInline(input.command, 180); + } + } + + return undefined; +} + +function summarizeCommandExecution(item: Record): string | undefined { + if (typeof item.command !== "string" || !item.command.trim()) { + return undefined; + } + + return truncateInline(stripShellWrapper(item.command), 180); +} + +function summarizeCommandExecutionResult(item: Record): string | undefined { + const command = summarizeCommandExecution(item); + const status = typeof item.status === "string" ? item.status : "completed"; + const exitCode = typeof item.exit_code === "number" ? `exit=${item.exit_code}` : undefined; + const firstLine = typeof item.aggregated_output === "string" + ? truncateInline( + item.aggregated_output + .split("\n") + .map((line) => line.trim()) + .find(Boolean) ?? "", + 180, + ) + : undefined; + + const summary = ["Command", status, exitCode].filter(Boolean).join(" "); + if (firstLine) return `${summary} | ${firstLine}`; + if (command) return `${summary} | ${command}`; + return summary || undefined; +} + +function stripShellWrapper(command: string): string { + const bashLcMatch = command.match(/^\/usr\/bin\/bash -lc (.+)$/); + if (!bashLcMatch) { + return command; + } + + const wrapped = bashLcMatch[1] ?? ""; + if ( + (wrapped.startsWith("'") && wrapped.endsWith("'")) || + (wrapped.startsWith("\"") && wrapped.endsWith("\"")) + ) { + return wrapped.slice(1, -1); + } + return wrapped; +} + function padLines(lines: string[], height: number): string { while (lines.length < height) lines.push(""); return lines.slice(0, height).join("\n"); diff --git a/src/tui/exec-event.ts b/src/tui/exec-event.ts index 3147fe0..8bb9c06 100644 --- a/src/tui/exec-event.ts +++ b/src/tui/exec-event.ts @@ -20,6 +20,11 @@ export type ExecEvent = steps: string[]; concurrency?: number; timeout: string; + agentProfiles?: Record; }; } | { diff --git a/src/tui/state-store.ts b/src/tui/state-store.ts index 26b0adc..2c3c7f5 100644 --- a/src/tui/state-store.ts +++ b/src/tui/state-store.ts @@ -65,6 +65,10 @@ export interface AgentRuntimeStats { memberId: string; dispatchCount: number; restartCount: number; + mcpAvailable?: string[]; + skillHints?: string[]; + observedMcpTools?: string[]; + observedSkills?: string[]; tokenSampleCount?: number; totalEstimatedTokens?: number; lastEstimatedTokens?: number; @@ -166,6 +170,40 @@ function arraysEqual(a?: string[], b?: string[]): boolean { return true; } +function uniqueSorted(values?: string[]): string[] | undefined { + if (!values || values.length === 0) return undefined; + return [...new Set(values.filter((value) => value.trim().length > 0))].sort(); +} + +function mergeUniqueSorted(existing: string[] | undefined, next: string[]): string[] | undefined { + if (next.length === 0) return existing; + return uniqueSorted([...(existing ?? []), ...next]); +} + +function extractSkillNames(text: string): string[] { + const matches = text.match(/(?:\.\/)?skills\/[A-Za-z0-9._-]+\/SKILL\.md/g) ?? []; + const names = new Set(); + for (const match of matches) { + const normalized = match.trim().replace(/^\.\/?/, ""); + const parts = normalized.split("/"); + if (parts.length >= 3 && parts[0] === "skills") { + names.add(parts[1]!); + } + } + return [...names].sort(); +} + +function extractObservedMcpTools(text: string): string[] { + const matches = [...text.matchAll(/mcp__([A-Za-z0-9_-]+)__([A-Za-z0-9_-]+)/g)]; + const tools = new Set(); + for (const match of matches) { + const server = match[1]; + const tool = match[2]; + if (server && tool) tools.add(`${server}.${tool}`); + } + return [...tools].sort(); +} + export class TuiStateStore implements ExecEventSink { readonly state: WorkflowUiState; dirty = false; @@ -345,6 +383,11 @@ export class TuiStateStore implements ExecEventSink { for (const stepId of event.definitionSummary.steps) { this.getOrCreateStep(stepId); } + for (const [memberId, profile] of Object.entries(event.definitionSummary.agentProfiles ?? {})) { + const stats = this.getOrCreateAgentRuntime(memberId); + stats.mcpAvailable = uniqueSorted(profile.mcpAvailable); + stats.skillHints = uniqueSorted(profile.skillHints); + } } } @@ -411,6 +454,7 @@ export class TuiStateStore implements ExecEventSink { if (instructions) { stats.currentInstructions = instructions; stats.lastInstructions = instructions; + stats.skillHints = mergeUniqueSorted(stats.skillHints, extractSkillNames(instructions)); } return; } @@ -429,13 +473,23 @@ export class TuiStateStore implements ExecEventSink { ): void { const step = this.getOrCreateStep(event.stepId); const we = event.event; + const memberId = agentMemberId(event.stepId); + const stats = memberId ? this.getOrCreateAgentRuntime(memberId) : undefined; switch (we.type) { case "stdout": step.logs.stdout.push(we.data); + if (stats) { + stats.observedMcpTools = mergeUniqueSorted(stats.observedMcpTools, extractObservedMcpTools(we.data)); + stats.observedSkills = mergeUniqueSorted(stats.observedSkills, extractSkillNames(we.data)); + } break; case "stderr": step.logs.stderr.push(we.data); + if (stats) { + stats.observedMcpTools = mergeUniqueSorted(stats.observedMcpTools, extractObservedMcpTools(we.data)); + stats.observedSkills = mergeUniqueSorted(stats.observedSkills, extractSkillNames(we.data)); + } break; case "progress": step.logs.progress.push(we.message); @@ -444,6 +498,10 @@ export class TuiStateStore implements ExecEventSink { message: we.message, percent: we.percent, }; + if (stats) { + stats.observedMcpTools = mergeUniqueSorted(stats.observedMcpTools, extractObservedMcpTools(we.message)); + stats.observedSkills = mergeUniqueSorted(stats.observedSkills, extractSkillNames(we.message)); + } break; case "patch": { const id = generateId(); diff --git a/src/types/index.ts b/src/types/index.ts index 4a1fcb9..be58e05 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,3 +6,4 @@ export * from "./worker-result.js"; export * from "./escalation.js"; export * from "./ipc-messages.js"; export * from "./worker-task-job-payload.js"; +export * from "./mcp-server.js"; diff --git a/src/types/mcp-server.ts b/src/types/mcp-server.ts new file mode 100644 index 0000000..b2d15aa --- /dev/null +++ b/src/types/mcp-server.ts @@ -0,0 +1,22 @@ +export interface McpServerConfig { + /** Stable server name used by worker-specific config formats. */ + name: string; + + /** Local stdio command. Mutually exclusive with `url`. */ + command?: string; + + /** Optional stdio command arguments. */ + args?: string[]; + + /** Optional stdio environment variables. */ + env?: Record; + + /** Optional remote MCP endpoint. Mutually exclusive with `command`. */ + url?: string; + + /** Optional bearer token env var for remote servers. */ + bearer_token_env_var?: string; + + /** Defaults to true when omitted. */ + enabled?: boolean; +} diff --git a/src/worker/adapters/base-process-adapter.ts b/src/worker/adapters/base-process-adapter.ts index fc59738..b84cb3b 100644 --- a/src/worker/adapters/base-process-adapter.ts +++ b/src/worker/adapters/base-process-adapter.ts @@ -51,6 +51,9 @@ export abstract class BaseProcessAdapter implements WorkerAdapter { abstract streamEvents(handle: WorkerHandle): AsyncIterable; protected abstract parseArtifacts(stdout: string): Artifact[]; protected abstract parseObservations(stdout: string): Observation[]; + protected buildProcessEnv(task: WorkerTask): Record | undefined { + return task.env; + } async startTask(task: WorkerTask): Promise { const command = this.buildCommand(task); @@ -58,13 +61,14 @@ export abstract class BaseProcessAdapter implements WorkerAdapter { // Merge process.env with task.env so we don't accidentally drop PATH/HOME/etc. // (Bun.spawn replaces the environment when `env` is provided.) - const mergedEnv: Record | undefined = task.env + const builtEnv = this.buildProcessEnv(task); + const mergedEnv: Record | undefined = builtEnv ? (() => { const base: Record = {}; for (const [k, v] of Object.entries(process.env)) { if (v !== undefined) base[k] = v; } - return { ...base, ...task.env }; + return { ...base, ...builtEnv }; })() : undefined; diff --git a/src/worker/adapters/codex-cli-adapter.ts b/src/worker/adapters/codex-cli-adapter.ts index e72dd00..6653c49 100644 --- a/src/worker/adapters/codex-cli-adapter.ts +++ b/src/worker/adapters/codex-cli-adapter.ts @@ -1,6 +1,7 @@ import { WorkerKind, WorkerCapability } from "../../types/index.js"; import type { WorkerTask } from "../../types/index.js"; import type { Artifact, Observation } from "../../types/index.js"; +import type { McpServerConfig } from "../../types/mcp-server.js"; import type { WorkerEvent, WorkerHandle } from "../worker-adapter.js"; import type { ProcessManager } from "../process-manager.js"; import { BaseProcessAdapter } from "./base-process-adapter.js"; @@ -8,9 +9,53 @@ import { BaseProcessAdapter } from "./base-process-adapter.js"; export interface CodexCliAdapterConfig { codexCommand?: string; defaultArgs?: string[]; + mcpServers?: McpServerConfig[]; gracePeriodMs?: number; } +function tomlString(value: string): string { + return JSON.stringify(value); +} + +function tomlStringArray(values: string[]): string { + return `[${values.map((value) => tomlString(value)).join(", ")}]`; +} + +function tomlInlineTable(values: Record): string { + return `{ ${Object.entries(values) + .map(([key, value]) => `${key} = ${tomlString(value)}`) + .join(", ")} }`; +} + +function buildCodexMcpOverrides(servers: McpServerConfig[]): string[] { + const args: string[] = []; + for (const server of servers) { + const base = `mcp_servers.${server.name}`; + if (server.enabled !== undefined) { + args.push("-c", `${base}.enabled=${server.enabled ? "true" : "false"}`); + } + if (server.url) { + args.push("-c", `${base}.url=${tomlString(server.url)}`); + if (server.bearer_token_env_var) { + args.push( + "-c", + `${base}.bearer_token_env_var=${tomlString(server.bearer_token_env_var)}`, + ); + } + continue; + } + if (!server.command) continue; + args.push("-c", `${base}.command=${tomlString(server.command)}`); + if (Array.isArray(server.args) && server.args.length > 0) { + args.push("-c", `${base}.args=${tomlStringArray(server.args)}`); + } + if (server.env && Object.keys(server.env).length > 0) { + args.push("-c", `${base}.env=${tomlInlineTable(server.env)}`); + } + } + return args; +} + function normalizeLegacyCodexArgs(args: string[]): string[] { let needsBypass = false; const preserved: string[] = []; @@ -65,6 +110,7 @@ export class CodexCliAdapter extends BaseProcessAdapter { this.config = { codexCommand: config.codexCommand ?? "codex", defaultArgs: config.defaultArgs ?? [], + mcpServers: config.mcpServers ?? [], gracePeriodMs: config.gracePeriodMs ?? 5000, }; } @@ -95,6 +141,10 @@ export class CodexCliAdapter extends BaseProcessAdapter { args.push(...defaultArgs); } + if (this.config.mcpServers.length > 0) { + args.push(...buildCodexMcpOverrides(this.config.mcpServers)); + } + // Always use JSONL output for reliable parsing/logging. if (!args.includes("--json")) { args.push("--json"); diff --git a/src/worker/adapters/opencode-adapter.ts b/src/worker/adapters/opencode-adapter.ts index 5439f19..0ff8c72 100644 --- a/src/worker/adapters/opencode-adapter.ts +++ b/src/worker/adapters/opencode-adapter.ts @@ -1,6 +1,7 @@ import { WorkerKind } from "../../types/index.js"; import type { WorkerTask } from "../../types/index.js"; import type { Artifact, Observation } from "../../types/index.js"; +import type { McpServerConfig } from "../../types/mcp-server.js"; import type { WorkerEvent, WorkerHandle } from "../worker-adapter.js"; import type { ProcessManager } from "../process-manager.js"; import { BaseProcessAdapter } from "./base-process-adapter.js"; @@ -8,6 +9,7 @@ import { BaseProcessAdapter } from "./base-process-adapter.js"; export interface OpenCodeAdapterConfig { openCodeCommand?: string; defaultArgs?: string[]; + mcpServers?: McpServerConfig[]; gracePeriodMs?: number; } @@ -75,6 +77,7 @@ export class OpenCodeAdapter extends BaseProcessAdapter { this.config = { openCodeCommand: config.openCodeCommand ?? "opencode", defaultArgs: config.defaultArgs ?? [], + mcpServers: config.mcpServers ?? [], gracePeriodMs: config.gracePeriodMs ?? 5000, }; } @@ -84,6 +87,63 @@ export class OpenCodeAdapter extends BaseProcessAdapter { return [this.config.openCodeCommand, ...args]; } + protected override buildProcessEnv(task: WorkerTask): Record | undefined { + const base = task.env ? { ...task.env } : {}; + if (this.config.mcpServers.length === 0) { + return Object.keys(base).length > 0 ? base : undefined; + } + + const existingContent = base.OPENCODE_CONFIG_CONTENT; + let parsed: Record = {}; + if (typeof existingContent === "string" && existingContent.trim() !== "") { + try { + const candidate = JSON.parse(existingContent); + if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) { + parsed = candidate as Record; + } + } catch { + // Ignore malformed caller-provided content and replace it with a valid object. + } + } + + const mcp = parsed["mcp"] && typeof parsed["mcp"] === "object" && !Array.isArray(parsed["mcp"]) + ? { ...(parsed["mcp"] as Record) } + : {}; + + for (const server of this.config.mcpServers) { + if (server.url) { + const remoteRecord: Record = { + type: "remote", + url: server.url, + }; + if (server.enabled !== undefined) { + remoteRecord.enabled = server.enabled; + } + mcp[server.name] = remoteRecord; + continue; + } + if (!server.command) continue; + const localRecord: Record = { + type: "local", + command: [server.command, ...(server.args ?? [])], + }; + if (server.enabled !== undefined) { + localRecord.enabled = server.enabled; + } + if (server.env && Object.keys(server.env).length > 0) { + localRecord.environment = server.env; + } + mcp[server.name] = localRecord; + } + + const configContent = { + ...parsed, + mcp, + }; + base.OPENCODE_CONFIG_CONTENT = JSON.stringify(configContent); + return base; + } + async cancel(handle: WorkerHandle): Promise { const active = this.activeProcesses.get(handle.handleId); if (!active) return; diff --git a/src/workflow/agent-catalog.ts b/src/workflow/agent-catalog.ts index 7d1751a..712e19a 100644 --- a/src/workflow/agent-catalog.ts +++ b/src/workflow/agent-catalog.ts @@ -1,6 +1,7 @@ import YAML from "yaml"; import type { StepDefinition, DurationString } from "./types.js"; +import type { McpServerConfig } from "../types/mcp-server.js"; export type AgentWorkerKind = NonNullable; export type AgentCapability = NonNullable[number]; @@ -14,6 +15,15 @@ export interface AgentProfile { /** Optional CLI arguments forwarded to the selected resident worker adapter. */ defaultArgs?: string[]; + /** Optional Claude Code MCP config files or JSON strings. */ + mcp_configs?: string[]; + + /** Optional generic MCP server definitions for worker-native injection. */ + mcp_servers?: McpServerConfig[]; + + /** Optional Claude Code flag to ignore non-explicit MCP sources. */ + strict_mcp_config?: boolean; + /** Default model identifier (adapter-specific). */ model?: string; @@ -96,6 +106,58 @@ function validateOptionalStringArray(value: unknown, field: string): void { } } +function validateOptionalBoolean(value: unknown, field: string): void { + if (value === undefined) return; + if (typeof value !== "boolean") { + throw new AgentCatalogParseError(`"${field}" must be a boolean`); + } +} + +function validateOptionalStringRecord(value: unknown, field: string): void { + if (value === undefined) return; + assertObject(value, field); + for (const [key, item] of Object.entries(value)) { + if (typeof item !== "string") { + throw new AgentCatalogParseError(`"${field}.${key}" must be a string`); + } + } +} + +function validateOptionalMcpServers(value: unknown, field: string): void { + if (value === undefined) return; + if (!Array.isArray(value)) { + throw new AgentCatalogParseError(`"${field}" must be an array`); + } + for (let i = 0; i < value.length; i++) { + const item = value[i]; + assertObject(item, `${field}[${i}]`); + validateOptionalNonEmptyString(item["name"], `${field}[${i}].name`); + const name = item["name"]; + if (typeof name === "string" && !/^[A-Za-z0-9_-]+$/.test(name)) { + throw new AgentCatalogParseError( + `"${field}[${i}].name" must match /^[A-Za-z0-9_-]+$/ (got "${name}")`, + ); + } + validateOptionalNonEmptyString(item["command"], `${field}[${i}].command`); + validateOptionalStringArray(item["args"], `${field}[${i}].args`); + validateOptionalStringRecord(item["env"], `${field}[${i}].env`); + validateOptionalNonEmptyString(item["url"], `${field}[${i}].url`); + validateOptionalNonEmptyString( + item["bearer_token_env_var"], + `${field}[${i}].bearer_token_env_var`, + ); + validateOptionalBoolean(item["enabled"], `${field}[${i}].enabled`); + + const hasCommand = typeof item["command"] === "string"; + const hasUrl = typeof item["url"] === "string"; + if (hasCommand === hasUrl) { + throw new AgentCatalogParseError( + `"${field}[${i}]" must specify exactly one of "command" or "url"`, + ); + } + } +} + function validateOptionalWorker(value: unknown, field: string): void { if (value === undefined) return; if (typeof value !== "string" || !VALID_WORKERS.has(value as AgentWorkerKind)) { @@ -180,6 +242,9 @@ export function parseAgentCatalog(yamlContent: string): AgentCatalog { validateOptionalNonEmptyString(obj["description"], `agents.${agentId}.description`); validateOptionalWorker(obj["worker"], `agents.${agentId}.worker`); validateOptionalStringArray(obj["defaultArgs"], `agents.${agentId}.defaultArgs`); + validateOptionalStringArray(obj["mcp_configs"], `agents.${agentId}.mcp_configs`); + validateOptionalMcpServers(obj["mcp_servers"], `agents.${agentId}.mcp_servers`); + validateOptionalBoolean(obj["strict_mcp_config"], `agents.${agentId}.strict_mcp_config`); validateOptionalNonEmptyString(obj["model"], `agents.${agentId}.model`); validateOptionalNonEmptyString(obj["variant"], `agents.${agentId}.variant`); // Allow empty string to intentionally clear inherited base_instructions. diff --git a/src/workflow/executor.ts b/src/workflow/executor.ts index a0dd2cc..b1664dc 100644 --- a/src/workflow/executor.ts +++ b/src/workflow/executor.ts @@ -80,6 +80,44 @@ const CONVERGENCE_ARTIFACT_DIR = "_convergence"; const MAX_CONVERGENCE_SIGNAL_ITEMS = 40; const DEFAULT_PRE_STEP_HOOK_CONCURRENCY = 2; +function extractSkillHints(text?: string): string[] { + if (!text) return []; + const matches = text.match(/(?:\.\/)?skills\/[A-Za-z0-9._-]+\/SKILL\.md/g) ?? []; + const names = new Set(); + for (const match of matches) { + const normalized = match.trim().replace(/^\.\/?/, ""); + const parts = normalized.split("/"); + if (parts.length >= 3 && parts[0] === "skills") { + names.add(parts[1]!); + } + } + return [...names].sort(); +} + +function summarizeAgentProfileUsage(profile: AgentProfile | undefined): { + mcpAvailable?: string[]; + skillHints?: string[]; +} { + if (!profile) return {}; + const mcpAvailable = new Set(); + for (const config of profile.mcp_configs ?? []) { + const trimmed = config.trim(); + if (!trimmed) continue; + const base = path.basename(trimmed).replace(/\.claude\.json$/i, "").replace(/\.json$/i, ""); + mcpAvailable.add(base); + } + for (const server of profile.mcp_servers ?? []) { + if (server.name?.trim()) { + mcpAvailable.add(server.name.trim()); + } + } + const skillHints = extractSkillHints(profile.base_instructions); + return { + mcpAvailable: mcpAvailable.size > 0 ? [...mcpAvailable].sort() : undefined, + skillHints: skillHints.length > 0 ? skillHints : undefined, + }; +} + export interface StepRunner { runStep( stepId: string, @@ -458,6 +496,17 @@ export class WorkflowExecutor { steps: Object.keys(this.stepDefs), concurrency: this.concurrency === Infinity ? undefined : this.concurrency, timeout: this.definition.timeout, + agentProfiles: this.definition.agents?.members + ? Object.fromEntries( + Object.entries(this.definition.agents.members).map(([memberId, memberCfg]) => [ + memberId, + { + agentId: memberCfg.agent, + ...summarizeAgentProfileUsage(this.agentCatalog?.[memberCfg.agent]), + }, + ]), + ) + : undefined, }, }); for (const [stepId, stepDef] of Object.entries(this.stepDefs)) { diff --git a/test/unit/agents/resident-agent.test.ts b/test/unit/agents/resident-agent.test.ts index 47630cf..22a0b9f 100644 --- a/test/unit/agents/resident-agent.test.ts +++ b/test/unit/agents/resident-agent.test.ts @@ -2,6 +2,8 @@ import { describe, test, expect } from "bun:test"; import { createAdapterForAgentProfile } from "../../../src/agents/resident-agent.js"; import { CodexCliAdapter } from "../../../src/worker/adapters/codex-cli-adapter.js"; +import { ClaudeCodeAdapter } from "../../../src/worker/adapters/claude-code-adapter.js"; +import { OpenCodeAdapter } from "../../../src/worker/adapters/opencode-adapter.js"; import type { ProcessManager } from "../../../src/worker/process-manager.js"; import { WorkerKind, @@ -61,4 +63,123 @@ describe("createAdapterForAgentProfile", () => { "Run live cluster verification", ]); }); + + test("adds Claude MCP config flags for resident CLAUDE_CODE agents", () => { + const adapter = createAdapterForAgentProfile( + {} as ProcessManager, + { + worker: "CLAUDE_CODE", + defaultArgs: ["--effort", "medium"], + mcp_configs: ["tools/apthctl-loop-mcp.claude.json"], + strict_mcp_config: true, + }, + ); + + expect(adapter).toBeInstanceOf(ClaudeCodeAdapter); + + const command = (adapter as ClaudeCodeAdapter).buildCommand( + createTask({ + workerKind: WorkerKind.CLAUDE_CODE, + model: "claude-sonnet-4-6", + }), + ); + + expect(command).toEqual([ + "claude", + "--effort", + "medium", + "--mcp-config", + "tools/apthctl-loop-mcp.claude.json", + "--strict-mcp-config", + "--model", + "claude-sonnet-4-6", + "--print", + "Run live cluster verification", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--allowedTools", + "View,Read,Glob,Grep,Edit,Write,NotebookEdit,Bash", + ]); + }); + + test("adds Codex MCP config overrides for resident CODEX_CLI agents", () => { + const adapter = createAdapterForAgentProfile( + {} as ProcessManager, + { + worker: "CODEX_CLI", + defaultArgs: ["--dangerously-bypass-approvals-and-sandbox"], + mcp_servers: [ + { + name: "apthctl_loop", + command: "bun", + args: ["run", "tools/apthctl-loop-mcp.ts"], + env: { LOOP_MODE: "test" }, + }, + ], + }, + ); + + expect(adapter).toBeInstanceOf(CodexCliAdapter); + + const command = (adapter as CodexCliAdapter).buildCommand( + createTask({ model: "openai/gpt-5.4" }), + ); + + expect(command).toEqual([ + "codex", + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "--model", + "gpt-5.4", + "-c", + 'mcp_servers.apthctl_loop.command="bun"', + "-c", + 'mcp_servers.apthctl_loop.args=["run", "tools/apthctl-loop-mcp.ts"]', + "-c", + 'mcp_servers.apthctl_loop.env={ LOOP_MODE = "test" }', + "--json", + "--cd", + "/tmp/test-workspace", + "Run live cluster verification", + ]); + }); + + test("passes generic MCP servers into resident OPENCODE agents", () => { + const adapter = createAdapterForAgentProfile( + {} as ProcessManager, + { + worker: "OPENCODE", + defaultArgs: ["--print-logs"], + mcp_servers: [ + { + name: "apthctl_loop", + command: "bun", + args: ["run", "tools/apthctl-loop-mcp.ts"], + }, + ], + }, + ); + + expect(adapter).toBeInstanceOf(OpenCodeAdapter); + + const command = (adapter as OpenCodeAdapter).buildCommand( + createTask({ + workerKind: WorkerKind.OPENCODE, + model: "openai/gpt-5.4", + }), + ); + + expect(command).toEqual([ + "opencode", + "run", + "--format", + "json", + "--print-logs", + "--model", + "openai/gpt-5.4", + "Run live cluster verification", + ]); + }); }); diff --git a/test/unit/tui/state-store.test.ts b/test/unit/tui/state-store.test.ts index 45ff5b8..ae0416f 100644 --- a/test/unit/tui/state-store.test.ts +++ b/test/unit/tui/state-store.test.ts @@ -30,6 +30,13 @@ describe("TuiStateStore", () => { steps: ["step-a", "step-b"], concurrency: 2, timeout: "30m", + agentProfiles: { + planner: { + agentId: "implementation_planner", + mcpAvailable: ["apthctl_loop", "roboppi_agents"], + skillHints: ["apthctl-todo-sync", "apthctl-rerun-provenance"], + }, + }, }, }; @@ -45,6 +52,11 @@ describe("TuiStateStore", () => { expect(store.state.stepOrder).toEqual(["step-a", "step-b"]); expect(store.state.steps.get("step-a")?.status).toBe("PENDING"); expect(store.state.steps.get("step-b")?.status).toBe("PENDING"); + expect(store.state.agentRuntime.get("planner")?.mcpAvailable).toEqual(["apthctl_loop", "roboppi_agents"]); + expect(store.state.agentRuntime.get("planner")?.skillHints).toEqual([ + "apthctl-rerun-provenance", + "apthctl-todo-sync", + ]); }); test("handles workflow_finished event", () => { @@ -124,14 +136,15 @@ describe("TuiStateStore", () => { phase: "executing", at: 1000, detail: { - instructions: "You are planner.\nCheck tasks.", + instructions: "You are planner.\nUse skills/apthctl-todo-sync/SKILL.md.\nCheck tasks.", }, }); let stats = store.state.agentRuntime.get("planner"); expect(stats?.dispatchCount).toBe(1); - expect(stats?.currentInstructions).toBe("You are planner.\nCheck tasks."); - expect(stats?.lastInstructions).toBe("You are planner.\nCheck tasks."); + expect(stats?.currentInstructions).toBe("You are planner.\nUse skills/apthctl-todo-sync/SKILL.md.\nCheck tasks."); + expect(stats?.lastInstructions).toBe("You are planner.\nUse skills/apthctl-todo-sync/SKILL.md.\nCheck tasks."); + expect(stats?.skillHints).toEqual(["apthctl-todo-sync"]); store.emit({ type: "step_phase", @@ -142,7 +155,40 @@ describe("TuiStateStore", () => { stats = store.state.agentRuntime.get("planner"); expect(stats?.currentInstructions).toBeUndefined(); - expect(stats?.lastInstructions).toBe("You are planner.\nCheck tasks."); + expect(stats?.lastInstructions).toBe("You are planner.\nUse skills/apthctl-todo-sync/SKILL.md.\nCheck tasks."); + }); + + test("tracks observed MCP tools and skills from worker logs", () => { + const store = new TuiStateStore(); + + store.emit({ + type: "worker_event", + stepId: "_agent:manual_verifier", + ts: 1000, + event: { + type: "stdout", + data: JSON.stringify({ + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + name: "mcp__apthctl_loop__live_cluster_reuse_candidates", + input: { issue_id: "issue-1" }, + }, + { + type: "tool_use", + name: "Read", + input: { file_path: "skills/apthctl-live-cluster-retention/SKILL.md" }, + }, + ], + }), + }, + }); + + const stats = store.state.agentRuntime.get("manual_verifier"); + expect(stats?.observedMcpTools).toEqual(["apthctl_loop.live_cluster_reuse_candidates"]); + expect(stats?.observedSkills).toEqual(["apthctl-live-cluster-retention"]); }); test("syncs workflow status summary and marks store dirty only when changed", () => { diff --git a/test/unit/worker/codex-cli-adapter.test.ts b/test/unit/worker/codex-cli-adapter.test.ts index a2c0340..0fe100b 100644 --- a/test/unit/worker/codex-cli-adapter.test.ts +++ b/test/unit/worker/codex-cli-adapter.test.ts @@ -169,6 +169,40 @@ describe("CodexCliAdapter", () => { ]); }); + test("should inject MCP server config overrides from adapter config", () => { + const customAdapter = new CodexCliAdapter(mockPm.pm, { + mcpServers: [ + { + name: "apthctl_loop", + command: "bun", + args: ["run", "tools/apthctl-loop-mcp.ts"], + env: { LOOP_MODE: "test" }, + }, + ], + }); + const task = createTask({ + capabilities: [WorkerCapability.READ], + }); + const command = customAdapter.buildCommand(task); + + expect(command).toEqual([ + "codex", + "exec", + "-c", + 'mcp_servers.apthctl_loop.command="bun"', + "-c", + 'mcp_servers.apthctl_loop.args=["run", "tools/apthctl-loop-mcp.ts"]', + "-c", + 'mcp_servers.apthctl_loop.env={ LOOP_MODE = "test" }', + "--json", + "--cd", + "/tmp/test-workspace", + "--sandbox", + "read-only", + "Fix the bug in main.ts", + ]); + }); + test("should not append full-auto or sandbox when bypass mode is already requested", () => { const task = createTask({ defaultArgs: ["--dangerously-bypass-approvals-and-sandbox"], diff --git a/test/unit/worker/opencode-adapter.test.ts b/test/unit/worker/opencode-adapter.test.ts index b6f97d3..66e657a 100644 --- a/test/unit/worker/opencode-adapter.test.ts +++ b/test/unit/worker/opencode-adapter.test.ts @@ -60,7 +60,7 @@ function makeMockProcess(exitCode: number = 0, stdoutData: string = "", stderrDa } function makeMockProcessManager(): ProcessManager & { - spawnCalls: Array<{ command: string[]; cwd?: string; abortSignal?: AbortSignal }>; + spawnCalls: Array<{ command: string[]; cwd?: string; abortSignal?: AbortSignal; env?: Record }>; gracefulShutdownCalls: Array<{ pid: number; graceMs: number }>; killCalls: Array<{ pid: number; signal?: string }>; _setMockProcess: (proc: ManagedProcess) => void; @@ -68,7 +68,7 @@ function makeMockProcessManager(): ProcessManager & { let mockProcess: ManagedProcess = makeMockProcess(); const pm = { - spawnCalls: [] as Array<{ command: string[]; cwd?: string; abortSignal?: AbortSignal }>, + spawnCalls: [] as Array<{ command: string[]; cwd?: string; abortSignal?: AbortSignal; env?: Record }>, gracefulShutdownCalls: [] as Array<{ pid: number; graceMs: number }>, killCalls: [] as Array<{ pid: number; signal?: string }>, _setMockProcess(proc: ManagedProcess) { @@ -78,6 +78,7 @@ function makeMockProcessManager(): ProcessManager & { pm.spawnCalls.push({ command: options.command, cwd: options.cwd, + env: options.env, abortSignal: options.abortSignal, }); return mockProcess; @@ -103,6 +104,7 @@ describe("OpenCodeAdapter", () => { const defaultConfig = { openCodeCommand: "opencode", defaultArgs: [] as string[], + mcpServers: [] as any[], gracePeriodMs: 5000, }; @@ -142,6 +144,30 @@ describe("OpenCodeAdapter", () => { ]); }); + test("keeps default args order stable when MCP servers are configured", () => { + const config = { + ...defaultConfig, + defaultArgs: ["--print-logs"], + mcpServers: [ + { + name: "apthctl_loop", + command: "bun", + args: ["run", "tools/apthctl-loop-mcp.ts"], + }, + ], + }; + const task = makeTask(); + const args = buildArgs(task, config); + + expect(args).toEqual([ + "run", + "--format", + "json", + "--print-logs", + "Fix the bug in main.ts", + ]); + }); + test("places instructions as the last argument", () => { const task = makeTask({ instructions: "Do something special" }); const args = buildArgs(task, defaultConfig); @@ -220,6 +246,41 @@ describe("OpenCodeAdapter", () => { expect(handle.handleId).toBeTruthy(); expect(handle.abortSignal).toBe(task.abortSignal); }); + + test("injects OPENCODE_CONFIG_CONTENT when MCP servers are configured", async () => { + const pm = makeMockProcessManager(); + const adapter = new OpenCodeAdapter(pm, { + mcpServers: [ + { + name: "apthctl_loop", + command: "bun", + args: ["run", "tools/apthctl-loop-mcp.ts"], + env: { LOOP_MODE: "test" }, + }, + { + name: "remote_docs", + url: "https://example.test/mcp", + enabled: false, + }, + ], + }); + + await adapter.startTask(makeTask()); + + const env = pm.spawnCalls[0]!.env; + expect(env).toBeDefined(); + const content = JSON.parse(env!.OPENCODE_CONFIG_CONTENT!); + expect(content.mcp.apthctl_loop).toEqual({ + type: "local", + command: ["bun", "run", "tools/apthctl-loop-mcp.ts"], + environment: { LOOP_MODE: "test" }, + }); + expect(content.mcp.remote_docs).toEqual({ + type: "remote", + url: "https://example.test/mcp", + enabled: false, + }); + }); }); describe("streamEvents", () => { diff --git a/test/unit/workflow/agent-catalog.test.ts b/test/unit/workflow/agent-catalog.test.ts index 2ad3970..c6a4c14 100644 --- a/test/unit/workflow/agent-catalog.test.ts +++ b/test/unit/workflow/agent-catalog.test.ts @@ -43,10 +43,79 @@ agents: ]); }); + it("parses optional claude MCP settings", () => { + const yaml = ` +version: "1" +agents: + planner: + worker: CLAUDE_CODE + mcp_configs: + - tools/apthctl-loop-mcp.claude.json + strict_mcp_config: true +`; + + const catalog = parseAgentCatalog(yaml); + expect(catalog.planner).toBeDefined(); + expect(catalog.planner!.mcp_configs).toEqual([ + "tools/apthctl-loop-mcp.claude.json", + ]); + expect(catalog.planner!.strict_mcp_config).toBe(true); + }); + + it("parses generic MCP server definitions", () => { + const yaml = ` +version: "1" +agents: + verifier: + worker: CODEX_CLI + mcp_servers: + - name: apthctl_loop + command: bun + args: [run, tools/apthctl-loop-mcp.ts] + env: + LOOP_MODE: test + - name: remote_docs + url: https://example.test/mcp + bearer_token_env_var: TEST_TOKEN + enabled: false +`; + + const catalog = parseAgentCatalog(yaml); + expect(catalog.verifier).toBeDefined(); + expect(catalog.verifier!.mcp_servers).toEqual([ + { + name: "apthctl_loop", + command: "bun", + args: ["run", "tools/apthctl-loop-mcp.ts"], + env: { LOOP_MODE: "test" }, + }, + { + name: "remote_docs", + url: "https://example.test/mcp", + bearer_token_env_var: "TEST_TOKEN", + enabled: false, + }, + ]); + }); + it("rejects wrong version", () => { const yaml = ` version: "2" agents: {} +`; + expect(() => parseAgentCatalog(yaml)).toThrow(AgentCatalogParseError); + }); + + it("rejects invalid MCP server definitions", () => { + const yaml = ` +version: "1" +agents: + verifier: + worker: CODEX_CLI + mcp_servers: + - name: bad.name + command: bun + url: https://example.test/mcp `; expect(() => parseAgentCatalog(yaml)).toThrow(AgentCatalogParseError); }); diff --git a/tests/at/agents-mcp.test.ts b/tests/at/agents-mcp.test.ts new file mode 100644 index 0000000..e4feea5 --- /dev/null +++ b/tests/at/agents-mcp.test.ts @@ -0,0 +1,348 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const REPO_ROOT = process.cwd(); +const TEST_TMP_ROOT = path.join(REPO_ROOT, ".roboppi-loop", "tmp", "at-agents-mcp"); +const CLI_TIMEOUT_MS = 45_000; +const AT_TIMEOUT = 60_000; + +type CliResult = { + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +}; + +type JsonRpcResponse = { + jsonrpc: "2.0"; + id: unknown; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +}; + +function createCleanEnv(extra?: Record): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const key of [ + "ROBOPPI_AGENTS_FILE", + "ROBOPPI_CORE_ENTRYPOINT", + "ROBOPPI_SUPERVISED_IPC_TRANSPORT", + "ROBOPPI_IPC_SOCKET_PATH", + "ROBOPPI_IPC_SOCKET_HOST", + "ROBOPPI_IPC_SOCKET_PORT", + "ROBOPPI_KEEPALIVE", + "ROBOPPI_KEEPALIVE_INTERVAL", + "ROBOPPI_IPC_TRACE", + "ROBOPPI_AGENTS_CONTEXT_DIR", + "ROBOPPI_AGENTS_MEMBER_ID", + "ROBOPPI_AGENTS_TEAM_ID", + ]) { + delete env[key]; + } + if (extra) { + for (const [k, v] of Object.entries(extra)) { + if (v === undefined) delete env[k]; + else env[k] = v; + } + } + return env; +} + +function shellQuote(arg: string): string { + return `'${arg.replace(/'/g, `'\"'\"'`)}'`; +} + +async function runAgentsCli(args: string[], env?: NodeJS.ProcessEnv): Promise { + return await new Promise((resolve) => { + const child = spawn(process.execPath, ["run", "src/cli.ts", "--", "agents", ...args], { + cwd: REPO_ROOT, + env: env ?? createCleanEnv(), + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let settled = false; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { stdout += chunk; }); + child.stderr.on("data", (chunk: string) => { stderr += chunk; }); + + child.once("error", (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ code: 1, signal: null, stdout, stderr: stderr + `\n[spawn error] ${err.message}` }); + }); + + child.once("close", (code, signal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ code, signal, stdout, stderr }); + }); + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGKILL"); + resolve({ + code: null, + signal: "SIGKILL", + stdout, + stderr: stderr + `\n[test harness] Process killed after ${CLI_TIMEOUT_MS}ms timeout`, + }); + }, CLI_TIMEOUT_MS); + }); +} + +function encodeMcpMessage(message: unknown): Buffer { + const body = Buffer.from(JSON.stringify(message), "utf8"); + return Buffer.concat([ + Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "utf8"), + body, + ]); +} + +function parseMcpResponses(stdout: string): JsonRpcResponse[] { + const buffer = Buffer.from(stdout, "utf8"); + const responses: JsonRpcResponse[] = []; + let offset = 0; + + while (offset < buffer.length) { + const headerEnd = buffer.indexOf("\r\n\r\n", offset, "utf8"); + if (headerEnd === -1) { + throw new Error(`Missing MCP header terminator in stdout: ${stdout}`); + } + const header = buffer.subarray(offset, headerEnd).toString("utf8"); + const match = header.match(/Content-Length:\s*(\d+)/i); + if (!match) { + throw new Error(`Missing Content-Length header in stdout: ${stdout}`); + } + const length = Number(match[1]); + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + length; + const body = buffer.subarray(bodyStart, bodyEnd).toString("utf8"); + responses.push(JSON.parse(body) as JsonRpcResponse); + offset = bodyEnd; + } + + return responses; +} + +async function runAgentsMcpSession( + contextDir: string, + requests: unknown[], + env?: NodeJS.ProcessEnv, +): Promise<{ responses: JsonRpcResponse[]; stderr: string; code: number | null }> { + const stdinDir = await mkdtemp(path.join(tmpdir(), "roboppi-agents-mcp-stdin-")); + const stdinPath = path.join(stdinDir, "mcp.input"); + const payload = Buffer.concat(requests.map((request) => encodeMcpMessage(request))); + await writeFile(stdinPath, payload); + + const result = await new Promise((resolve) => { + const child = spawn( + "bash", + [ + "-lc", + `${shellQuote(process.execPath)} run src/cli.ts -- agents mcp --context ${shellQuote(contextDir)} < ${shellQuote(stdinPath)}`, + ], + { + cwd: REPO_ROOT, + env: env ?? createCleanEnv(), + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let stdout = ""; + let stderr = ""; + let settled = false; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { stdout += chunk; }); + child.stderr.on("data", (chunk: string) => { stderr += chunk; }); + + child.once("error", (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ code: 1, signal: null, stdout, stderr: stderr + `\n[spawn error] ${err.message}` }); + }); + + child.once("close", (code, signal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ code, signal, stdout, stderr }); + }); + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGKILL"); + resolve({ + code: null, + signal: "SIGKILL", + stdout, + stderr: stderr + `\n[test harness] Process killed after ${CLI_TIMEOUT_MS}ms timeout`, + }); + }, CLI_TIMEOUT_MS); + }); + + await rm(stdinDir, { recursive: true, force: true }); + return { + responses: parseMcpResponses(result.stdout), + stderr: result.stderr, + code: result.code, + }; +} + +let contextDir: string; + +beforeEach(async () => { + await mkdir(TEST_TMP_ROOT, { recursive: true }); + contextDir = await mkdtemp(path.join(TEST_TMP_ROOT, "agents-mcp-at-")); +}); + +afterEach(async () => { + await rm(contextDir, { recursive: true, force: true }); +}); + +describe("roboppi agents mcp", () => { + it("initializes and lists the structured tools", async () => { + await runAgentsCli(["init", "--context", contextDir, "--team", "test-team"]); + + const env = createCleanEnv({ + ROBOPPI_AGENTS_CONTEXT_DIR: contextDir, + ROBOPPI_AGENTS_MEMBER_ID: "lead", + }); + + const { responses, stderr, code } = await runAgentsMcpSession(contextDir, [ + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + clientInfo: { name: "test", version: "1.0.0" }, + capabilities: {}, + }, + }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }, + ], env); + + expect(code).toBe(0); + expect(stderr).toBe(""); + expect(responses).toHaveLength(2); + expect(responses[0]?.error).toBeUndefined(); + expect((responses[0]?.result as any).capabilities.tools).toEqual({}); + + const tools = (responses[1]?.result as any).tools as Array<{ name: string }>; + expect(tools.some((tool) => tool.name === "agents_specialist_activate")).toBe(true); + expect(tools.some((tool) => tool.name === "agents_status_set")).toBe(true); + expect(tools.some((tool) => tool.name === "agents_tasks_claim")).toBe(true); + }, AT_TIMEOUT); + + it("activates and deactivates a specialist and updates workflow status", async () => { + await runAgentsCli(["init", "--context", contextDir, "--team", "test-team"]); + + const env = createCleanEnv({ + ROBOPPI_AGENTS_CONTEXT_DIR: contextDir, + ROBOPPI_AGENTS_MEMBER_ID: "lead", + }); + + const { responses, stderr, code } = await runAgentsMcpSession(contextDir, [ + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "agents_specialist_activate", + arguments: { + member_id: "manual_verifier", + agent_id: "manual_verifier", + name: "manual_verifier", + task: { + title: "Run manual verification", + description: "Bootstrap a fresh kind cluster and capture the first blocker.", + tags: ["manual", "kind"], + }, + }, + }, + }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "agents_list_members", + arguments: {}, + }, + }, + { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { + name: "agents_status_set", + arguments: { + summary: "Manual verification is running.", + blockers: ["waiting for cluster bootstrap"], + next_actions: ["watch manual_verifier logs"], + }, + }, + }, + { + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { + name: "agents_status_get", + arguments: {}, + }, + }, + { + jsonrpc: "2.0", + id: 5, + method: "tools/call", + params: { + name: "agents_specialist_deactivate", + arguments: { + member_id: "manual_verifier", + }, + }, + }, + ], env); + + expect(code).toBe(0); + expect(stderr).toBe(""); + expect(responses).toHaveLength(5); + + const activateContent = (responses[0]?.result as any).structuredContent; + expect(activateContent.ok).toBe(true); + expect(activateContent.member_id).toBe("manual_verifier"); + expect(activateContent.task_id).toBeTruthy(); + + const roster = (responses[1]?.result as any).structuredContent.members as Array<{ member_id: string; role: string; agent?: string }>; + expect(roster.find((member) => member.member_id === "manual_verifier")).toEqual( + expect.objectContaining({ member_id: "manual_verifier", role: "member", agent: "manual_verifier" }), + ); + + expect((responses[2]?.result as any).structuredContent.ok).toBe(true); + expect((responses[3]?.result as any).structuredContent.status.summary).toBe("Manual verification is running."); + expect((responses[4]?.result as any).structuredContent.role).toBe("dormant"); + }, AT_TIMEOUT); +}); diff --git a/tests/unit/tui/agent-overview-tab.test.ts b/tests/unit/tui/agent-overview-tab.test.ts index 98c2c52..aba9a09 100644 --- a/tests/unit/tui/agent-overview-tab.test.ts +++ b/tests/unit/tui/agent-overview-tab.test.ts @@ -166,6 +166,43 @@ describe("renderAgentOverviewTab", () => { expect(output).toContain("Reply to lead."); }); + it("renders configured and observed MCP/skill usage", () => { + const step = makeStep("_agent:manual_verifier", { + startedAt: Date.parse("2026-03-09T00:10:00.000Z"), + phase: "ready", + }); + const state = makeState([step], [{ memberId: "manual_verifier", name: "manual_verifier", role: "member", agentId: "manual_verifier" }]); + state.agentRuntime.set("manual_verifier", { + memberId: "manual_verifier", + dispatchCount: 1, + restartCount: 0, + totalDispatchActiveMs: 10_000, + mcpAvailable: ["apthctl_loop"], + skillHints: ["apthctl-live-cluster-retention", "apthctl-kubernetes-debug"], + observedMcpTools: ["apthctl_loop.live_cluster_reuse_candidates"], + observedSkills: ["apthctl-live-cluster-retention"], + }); + + const output = renderAgentOverviewTab( + state, + step, + state.agentRoster.get("manual_verifier"), + "manual_verifier", + 120, + 30, + ); + + expect(output).toContain("MCP Ready:"); + expect(output).toContain("apthctl_loop"); + expect(output).toContain("MCP Used:"); + expect(output).toContain("apthctl_loop.live_cluster_reuse_candidates"); + expect(output).toContain("Skill Hints:"); + expect(output).toContain("apthctl-live-cluster-retention"); + expect(output).toContain("apthctl-kubernetes-debug"); + expect(output).toContain("Skills Read:"); + expect(output).toContain("apthctl-live-cluster-retention"); + }); + it("renders last instructions after a dispatch has finished", () => { const step = makeStep("_agent:planner", { startedAt: Date.parse("2026-03-09T00:10:00.000Z"), diff --git a/tests/unit/tui/logs-tab.test.ts b/tests/unit/tui/logs-tab.test.ts index b7d9a2e..64875da 100644 --- a/tests/unit/tui/logs-tab.test.ts +++ b/tests/unit/tui/logs-tab.test.ts @@ -153,4 +153,48 @@ describe("renderLogsTab", () => { expect(output).toContain("[roboppi note]"); expect(output).toContain("roboppi truncated worker logs"); }); + + it("summarizes codex item lifecycle events for command execution and agent messages", () => { + const step = makeStep(); + step.logs.stdout.push(JSON.stringify({ + type: "item.started", + item: { + id: "item_32", + type: "command_execution", + command: "/usr/bin/bash -lc 'kind get clusters || true'", + aggregated_output: "", + exit_code: null, + status: "in_progress", + }, + })); + step.logs.stdout.push(JSON.stringify({ + type: "item.completed", + item: { + id: "item_32", + type: "command_execution", + command: "/usr/bin/bash -lc 'kind get clusters || true'", + aggregated_output: "No kind clusters found.\n", + exit_code: 0, + status: "completed", + }, + })); + step.logs.stdout.push(JSON.stringify({ + type: "item.completed", + item: { + id: "item_33", + type: "agent_message", + text: "I have confirmed the exact CLI surfaces I need before spending on create flow.", + }, + })); + + const output = renderLogsTab(step, 180, 20); + expect(output).toContain("[tool call]"); + expect(output).toContain("Command started | kind get clusters || true"); + expect(output).toContain("[result]"); + expect(output).toContain("Command completed exit=0 | No kind clusters found."); + expect(output).toContain("[message]"); + expect(output).toContain("I have confirmed the exact CLI surfaces I need"); + expect(output).not.toContain("structured event: item.started"); + expect(output).not.toContain("structured event: item.completed"); + }); }); From 8491878e9e38f03191a997cadc486a4f3978e2f1 Mon Sep 17 00:00:00 2001 From: reoring Date: Mon, 9 Mar 2026 12:55:16 +0900 Subject: [PATCH 5/6] test(at): avoid bun stdin pipe flake in cli harness --- tests/at/agents-cli.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/at/agents-cli.test.ts b/tests/at/agents-cli.test.ts index 66cd3fa..836b330 100644 --- a/tests/at/agents-cli.test.ts +++ b/tests/at/agents-cli.test.ts @@ -81,7 +81,11 @@ async function runAgentsCli(args: string[], env?: NodeJS.ProcessEnv, stdinData?: : spawn(process.execPath, ["run", "src/cli.ts", "--", "agents", ...args], { cwd: REPO_ROOT, env: env ?? createCleanEnv(), - stdio: ["pipe", "pipe", "pipe"], + // On CI, a writable stdin pipe for Bun child processes can occasionally + // keep the CLI invocation from closing promptly even though these + // commands never read stdin. Use `ignore` unless stdin is explicitly + // redirected via the shell path above. + stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; @@ -93,10 +97,6 @@ async function runAgentsCli(args: string[], env?: NodeJS.ProcessEnv, stdinData?: child.stdout.on("data", (chunk: string) => { stdout += chunk; }); child.stderr.on("data", (chunk: string) => { stderr += chunk; }); - if (!stdinPath && child.stdin) { - child.stdin.end(); - } - // Handle process error (e.g. ENOENT, spawn failure) child.once("error", (err) => { if (settled) return; From d33e1097e3f7273710c91574a15a7d5ca889660b Mon Sep 17 00:00:00 2001 From: reoring Date: Mon, 9 Mar 2026 13:15:40 +0900 Subject: [PATCH 6/6] test(at): wrap agents cli invocations for ci stability --- tests/at/agents-cli.test.ts | 46 +++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/tests/at/agents-cli.test.ts b/tests/at/agents-cli.test.ts index 836b330..641d3cf 100644 --- a/tests/at/agents-cli.test.ts +++ b/tests/at/agents-cli.test.ts @@ -65,28 +65,30 @@ async function runAgentsCli(args: string[], env?: NodeJS.ProcessEnv, stdinData?: } return new Promise((resolve) => { - const child = stdinPath - ? spawn( - "bash", - [ - "-lc", - `${shellQuote(process.execPath)} run src/cli.ts -- agents ${args.map(shellQuote).join(" ")} < ${shellQuote(stdinPath)}`, - ], - { - cwd: REPO_ROOT, - env: env ?? createCleanEnv(), - stdio: ["ignore", "pipe", "pipe"], - }, - ) - : spawn(process.execPath, ["run", "src/cli.ts", "--", "agents", ...args], { - cwd: REPO_ROOT, - env: env ?? createCleanEnv(), - // On CI, a writable stdin pipe for Bun child processes can occasionally - // keep the CLI invocation from closing promptly even though these - // commands never read stdin. Use `ignore` unless stdin is explicitly - // redirected via the shell path above. - stdio: ["ignore", "pipe", "pipe"], - }); + const command = [ + "exec", + shellQuote(process.execPath), + "run", + "src/cli.ts", + "--", + "agents", + ...args.map(shellQuote), + ].join(" "); + const child = spawn( + "bash", + [ + "-lc", + stdinPath ? `${command} < ${shellQuote(stdinPath)}` : command, + ], + { + cwd: REPO_ROOT, + env: env ?? createCleanEnv(), + // Always route through `bash -lc exec ...` because Bun 1.3.8 can + // intermittently hang when a `bun run ...` process is spawned directly + // under this AT harness on CI. + stdio: ["ignore", "pipe", "pipe"], + }, + ); let stdout = ""; let stderr = "";