Compare commits

...

6 Commits

Author SHA1 Message Date
74da084521 feat(conductor): Wave 2 — parallel batch execution + SWITCH branching step
- Parallel batch execution: batch field on Step, batchConfig on Flow,
  batch-aware readySteps with maxConcurrent gating, getReadyInBatch helper
- SWITCH branching step: new 'switch' StepKind with cases/programmed conditions,
  resolveSwitch() pure function, switch-excluded steps tracked in
  SchedulerState, non-selected branches excluded from execution
2026-06-08 03:00:06 +00:00
c860b6c4b7 feat: Wave 1 complete — state machine, Paseo hub, collision detection, PTY search
- Task state machine: TIMED_OUT state, retriable steps, timeout detection
- Paseo hub: paseo-client.ts (HTTP+CLI), PaseoBackend (AgentBackend), 14 tests
- Collision detection: collision-detector.ts, conflict-index.ts, ws-frames type
- PTY search: ring buffer, search route, capture-pane fallback
2026-06-08 02:45:17 +00:00
c4ee377dbc feat(conductor): task state machine — TIMED_OUT state and retriable steps
- Add 'timed_out' to flow_runs/flow_steps CHECK constraints
- Add retry_count and max_retries columns to flow_steps
- Add timeout detection in advanceInner loop (configurable FLOW_STEP_TIMEOUT_MS)
- Add retriable logic: re-dispatch on timeout if maxRetries > 0 and retryCount < maxRetries
- Add isRetriable() + shouldRetry() pure decision functions
- Add timed_out handling to reconcileResumeStep and reconcileRun
- Add 'timed_out' to ws-frames enum, publishStep status type
2026-06-08 02:43:45 +00:00
f2401352a8 chore: update pnpm-lock.yaml for @ai-sdk/deepseek 2026-06-08 02:28:32 +00:00
abe9c5a3a8 feat: Paseo-like orchestrator Phase 1-2 — trace system, session persistence, timeline, run_command, auto-fix loop
Phase 1: Trace System + Observability
- tool_traces DB table + insert/update service
- tool_trace_start/tool_trace_finish WS frames (contracts + FE types)
- Instrumented tool-phase.ts with timing around every tool call
- GET /api/chats/:id/traces paginated endpoint
- Trace viewer frontend (collapsible panel with timing bars + token breakdown)

Phase 2: Session Persistence + Resume
- agent_snapshots table (UPSERT per chat, persisted on turn boundaries)
- save/load/delete service functions
- Agent snapshot sent on WS reconnect
- Session timeline view (vertical timeline with scroll-to + restore)

Tooling:
- run_command tool (execFile, 30s timeout, 32KB cap, path-guarded)
- Auto-fix loop: after write tools, runs pnpm build, injects errors into next turn
2026-06-08 02:26:47 +00:00
7cb692d8be feat: Phase 4 teardown — remove Go codecontext sidecar from deployment
- Remove codecontext service block from docker-compose.yml
- Remove CODECONTEXT_URL env var
- Delete codecontext/Dockerfile
- Update callCodecontext() to try boocontext MCP first with HTTP fallback
- Graceful degradation: if boocontext MCP unavailable, tools still work via HTTP
2026-06-08 02:16:02 +00:00
43 changed files with 4449 additions and 219 deletions

View File

@@ -0,0 +1,239 @@
# Paseo-like Orchestrator — Implementation Plan
> **Goal:** Transform BooCode into a Paseo-style thin-client orchestration layer with observability, dynamic workflows, resumability, background subagents, multi-modal, and cache shape telemetry.
>
> **Architecture:** Durable agent execution engine beneath thin chat/coder frontends. Trace system as foundation, workflow engine as the structural addition, everything else layered on top.
>
> **Inspired by:** Paseo (agent lifecycle, worktree isolation), Whale (workflow engine, cache telemetry), OpenCode (session resume), Claude Code (workflow script format).
---
## TL;DR
> **Quick Summary**: Build a durable orchestration layer with trace observability, dynamic JS workflows, session persistence, background subagents, and multi-modal support over 5 phases.
>
> **Deliverables**:
> - Trace system with DB persistence + viewer UI
> - Dynamic workflow engine (JS sandbox, agent/parallel/pipeline)
> - Workflow resumability (hash-based step caching)
> - Background subagent runtime
> - Session persistence across refreshes
> - Cache shape telemetry (DeepSeek KV cache viz)
> - Multi-modal attachment support
>
> **Estimated Effort**: XL — 5 phases, ~2-3 weeks total
> **Parallel Execution**: YES — phases 1-2 can partially overlap
> **Critical Path**: Trace system → Workflow engine → All downstream features
---
## Context
### Original Request
User wants BooCode to become "like Paseo — a thin client" with observability, dynamic workflows, session persistence, background agents, multi-modal, cache shape telemetry, and workflow resumability. They invoked skills across model evaluation, long context, SGLang, LangChain, LangSmith, agentic eval, agent harness construction, agent governance, and chat SDKs — indicating broad ambition for a production-quality AI coding platform.
### Key Decisions
- **Trace system first**: Foundation for all debugging and optimization
- **isolated-vm for workflow sandbox**: Node-native, no external deps
- **DB-backed sessions**: Postgres for trace store + session state
- **Existing WS frames + new `tool_trace` frame**: Live streaming to frontend
- **Phase ordering**: Foundation (trace) → UX (persistence) → Power (workflows) → Polish (background/multi-modal/cache)
---
## Phases
### Phase 1: Trace System + Observability
**Est. effort**: 3-4 days
Core observability infrastructure. Every tool call gets timed, logged, and persisted.
**Deliverables**:
- `tool_traces` DB table (id, session_id, chat_id, turn_number, tool_name, input, output, started_at, finished_at, latency_ms, tokens_used, cache_tokens, reasoning_tokens, error, outcome)
- Instrumentation in `tool-phase.ts` wrapping `executeToolCall` with start/end timing
- `tool_trace` WS frame type for live streaming to frontend
- GET `/api/chats/:id/traces` endpoint (paginated)
- Trace viewer pane (collapsible tree, timing bars, expand/collapse per call)
**Files to create**: 5-7 files across server + web + contracts
**Dependencies**: None — standalone feature
---
### Phase 2: Session Persistence + Resume
**Est. effort**: 2-3 days
Agent state survives browser refresh. Active sessions can be resumed.
**Deliverables**:
- Serialize active agent state to DB on each turn boundary
- Restore state on WS reconnect (existing `snapshot` frame enhanced)
- Agent session timeline view (history of all turns in a session)
- Coder pane rehydrates from persisted state
**Files to modify**: ws.ts, useSessionStream.ts, session store, dispatcher
**Dependencies**: None — standalone, but benefits from Phase 1 trace data
---
### Phase 3: Dynamic Workflow Engine
**Est. effort**: 5-7 days
JS sandbox for multi-agent orchestration. Claude Code compatible.
**Deliverables**:
- `isolated-vm` sandbox (or Node `vm` module with restricted context)
- Workflow API: `agent()`, `parallel()`, `pipeline()`, `phase()`, `budget()`, `log()`, `args`
- Workflow file discovery (`.boocode/workflows/*.js` → project, `~/.boocode/workflows/*.js` → global)
- Built-in workflow catalog (deep-research, multi-review, etc.)
- Workflow manager with concurrency limits, token budgets
- Integration with existing Orchestrator panel for UI
**Files to create**: 10-15 files (workflow runtime, scheduler, tool bridge, manager, catalog)
**Dependencies**: Phase 1 traces feed into workflow observability
**Workflow Resumability** (within Phase 3):
- SHA-256 hash of agent spec (prompt + options)
- Cache completed results by hash
- On re-run, skip cached agents, only execute new/changed ones
- In-memory cache for current session, optional DB persistence
**Est. effort**: 1-2 days within Phase 3
---
### Phase 4: Background Subagents
**Est. effort**: 2-3 days
Non-blocking subagent execution. `spawn_subagent` returns immediately, results collected later.
**Deliverables**:
- Background task queue (reuses existing `tasks` table)
- `spawn_subagent` tool that creates a task and returns immediately
- `subagent_status` tool to poll completion
- `subagent_result` tool to retrieve output
- Background agent pane showing running/completed subagents
- Notifications via hooks when background tasks complete
**Files to create**: 3-5 files across server + web
**Dependencies**: Phase 1 traces, Phase 2 session persistence
---
### Phase 5: Multi-modal + Cache Shape (Polish)
**Est. effort**: 2-3 days
Image/file attachment support + DeepSeek cache hit visualization.
**Deliverables (Multi-modal)**:
- Image/file attachment storage (tmpfs, referenced in message)
- Forward image content through DeepSeek API's multimodal support
- Render attached images in message bubble
- Model can "see" screenshots, diagrams, UI mocks
**Deliverables (Cache Shape)**:
- Extract `prompt_cache_hit_tokens` from DeepSeek provider metadata
- Build cache segment visualization (system prompt, tool schema, conversation)
- Per-turn cache hit rate in trace viewer
- Cumulative cache stats in session view
**Files to create**: 3-5 files
**Dependencies**: Phase 1 traces (for cache shape), existing DeepSeek integration
---
## Execution Strategy
### Parallel Execution Waves
```
Wave 1 (Start Immediately):
├── Phase 1: Trace system backend (tool_traces table + instrumentation) [deep]
├── Phase 1: Trace viewer frontend [visual-engineering]
└── Phase 2: Session persistence backbone [deep]
Wave 2 (After Wave 1):
├── Phase 3: Workflow engine sandbox + API surface [deep]
├── Phase 3: Workflow file discovery + manager [unspecified-high]
├── Phase 3: Workflow resumability cache [quick]
└── Phase 4: Background subagent queue + tools [unspecified-high]
Wave 3 (After Wave 2):
├── Phase 4: Background agent pane + notifications [visual-engineering]
├── Phase 5: Multi-modal attachment pipeline [deep]
└── Phase 5: Cache shape telemetry UI [visual-engineering]
Wave FINAL:
├── F1: Plan compliance audit (oracle)
├── F2: Code quality review (unspecified-high)
├── F3: Integration QA (unspecified-high)
└── F4: Scope fidelity check (deep)
```
---
## TODOs
> Phase 1: Trace System + Observability
- [ ] 1. Create tool_traces DB table + migration
- [ ] 2. Add tool_trace WS frame + contracts schema
- [ ] 3. Instrument tool-phase.ts with start/end timing
- [ ] 4. Add GET /api/chats/:id/traces endpoint
- [ ] 5. Build trace viewer frontend component
> Phase 2: Session Persistence + Resume
- [ ] 6. Serialize agent state to DB on turn boundaries
- [ ] 7. Restore state on WS reconnect
- [ ] 8. Agent session timeline view
> Phase 3: Dynamic Workflow Engine
- [ ] 9. Create isolated-vm workflow sandbox
- [ ] 10. Implement agent/parallel/pipeline primitives
- [ ] 11. Workflow file discovery system
- [ ] 12. Workflow manager + built-in catalog
- [ ] 13. Workflow resumability (hash-based cache)
- [ ] 14. Workflow UI integration with Orchestrator panel
> Phase 4: Background Subagents
- [ ] 15. Background task queue + spawn_subagent tool
- [ ] 16. subagent_status + subagent_result tools
- [ ] 17. Background agent pane
> Phase 5: Multi-modal + Cache Shape
- [ ] 18. Multi-modal attachment pipeline
- [ ] 19. Image render in message bubble
- [ ] 20. Cache shape telemetry data pipeline
- [ ] 21. Cache shape visualization in trace viewer
---
## Success Criteria
- Tool trace viewer shows every call with timing bars and token costs
- Browser refresh preserves agent session state
- Workflow scripts run in isolated sandbox with agent/parallel/pipeline
- Re-running a workflow skips cached agents (hash-based)
- Background subagents run independently, results collected later
- Model can see attached images in chat
- Cache hit rate visible per-turn and cumulative

View File

@@ -5,6 +5,7 @@ import { getPool, closeDb } from './db.js';
import { registerHealthRoutes } from './routes/health.js'; import { registerHealthRoutes } from './routes/health.js';
import { registerTerminalRoutes } from './routes/terminals.js'; import { registerTerminalRoutes } from './routes/terminals.js';
import { registerSessionRoutes } from './routes/sessions.js'; import { registerSessionRoutes } from './routes/sessions.js';
import { registerSearchRoutes } from './routes/search.js';
import { registerWsAttachRoute } from './ws/attach.js'; import { registerWsAttachRoute } from './ws/attach.js';
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -35,6 +36,7 @@ async function main(): Promise<void> {
registerHealthRoutes(app); registerHealthRoutes(app);
registerTerminalRoutes(app, config.TMUX_CONF_PATH); registerTerminalRoutes(app, config.TMUX_CONF_PATH);
registerSessionRoutes(app); registerSessionRoutes(app);
registerSearchRoutes(app, config.TMUX_CONF_PATH);
registerWsAttachRoute(app, config.TMUX_CONF_PATH); registerWsAttachRoute(app, config.TMUX_CONF_PATH);
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {

View File

@@ -33,6 +33,7 @@ export function register(
export function unregister(paneId: string): void { export function unregister(paneId: string): void {
sessions.delete(paneId); sessions.delete(paneId);
ringBuffers.delete(paneId);
} }
export function list(): SessionMeta[] { export function list(): SessionMeta[] {
@@ -42,3 +43,120 @@ export function list(): SessionMeta[] {
export function get(paneId: string): SessionMeta | undefined { export function get(paneId: string): SessionMeta | undefined {
return sessions.get(paneId); return sessions.get(paneId);
} }
// ── Ring buffer for PTY output search ──────────────────────────────────────
export interface SearchMatch {
line: number;
content: string;
contextBefore: string[];
contextAfter: string[];
}
const ringBuffers = new Map<string, string[]>();
/**
* Append raw PTY data to the ring buffer for a given pane.
* Splits incoming data on newlines and pushes each line into the buffer,
* trimming to `maxLines` (default 5000) from the tail.
*/
export function appendOutput(
paneId: string,
data: string,
maxLines: number = 5000,
): void {
let buf = ringBuffers.get(paneId);
if (!buf) {
buf = [];
ringBuffers.set(paneId, buf);
}
// Split on newlines — each chunk may contain multiple complete lines and
// potentially a trailing partial line (which we store as-is; the next chunk
// will either complete it or be another partial).
const lines = data.split('\n');
// The first element of `lines` may be a continuation of the last partial
// line from the previous append. If the buffer is non-empty and the last
// stored entry is a partial (no trailing newline previously), glue them.
// We detect "partial" by checking whether `data` ended with '\n' — if it
// did, the last element after split is '' (empty) which we drop.
const endedWithNewline = data.endsWith('\n');
if (endedWithNewline) {
// The final empty-string element is discarded.
lines.pop();
}
if (buf.length > 0 && lines.length > 0) {
// Concatenate the last partial line in the buffer with the first split
// segment. This avoids splitting ANSI sequences or text across chunks.
buf[buf.length - 1] = (buf[buf.length - 1] ?? '') + (lines[0] ?? '');
lines.shift();
}
for (const line of lines) {
buf.push(line);
}
// Trim from head if over maxLines
if (buf.length > maxLines) {
buf = buf.slice(buf.length - maxLines);
ringBuffers.set(paneId, buf);
}
}
/**
* Search the ring buffer for a pane using a regex pattern.
* Returns matches with optional context lines before and after each match.
*/
export function searchRingBuffer(
paneId: string,
pattern: string,
opts?: { limit?: number; context?: number },
): SearchMatch[] {
const buf = ringBuffers.get(paneId);
if (!buf || buf.length === 0) return [];
const limit = opts?.limit ?? 50;
const context = opts?.context ?? 0;
let re: RegExp;
try {
re = new RegExp(pattern, 'u');
} catch {
return []; // invalid regex — caller should validate, but be defensive
}
const results: SearchMatch[] = [];
for (let i = 0; i < buf.length; i++) {
if (results.length >= limit) break;
if (re.test(buf[i]!)) {
const contextBefore: string[] = [];
const contextAfter: string[] = [];
for (let c = 1; c <= context; c++) {
const ci = i - c;
if (ci >= 0) contextBefore.unshift(buf[ci]!);
}
for (let c = 1; c <= context; c++) {
const ci = i + c;
if (ci < buf.length) contextAfter.push(buf[ci]!);
}
results.push({
line: i + 1, // 1-based line number for display
content: buf[i]!,
contextBefore,
contextAfter,
});
}
}
return results;
}
/**
* Remove the ring buffer for a pane. Called on session kill / pane close.
*/
export function clearBuffer(paneId: string): void {
ringBuffers.delete(paneId);
}

View File

@@ -0,0 +1,167 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { sanitizeId, tmuxSessionName, capturePane } from '../pty/manager.js';
import { searchRingBuffer, clearBuffer } from '../pty/registry.js';
const ParamsSchema = z.object({
sid: z.string(),
pid: z.string(),
});
const MAX_PATTERN_LENGTH = 200;
// Zod-refined string: reject empty and overly-long patterns to prevent ReDoS
const PatternQuerySchema = z
.string()
.min(1, 'pattern is required')
.max(MAX_PATTERN_LENGTH, `pattern must not exceed ${MAX_PATTERN_LENGTH} characters`);
const QuerySchema = z.object({
pattern: PatternQuerySchema,
limit: z.coerce.number().int().min(1).max(500).default(50),
context: z.coerce.number().int().min(0).max(50).default(0),
});
interface SearchMatch {
line: number;
content: string;
contextBefore: string[];
contextAfter: string[];
}
interface SearchResponse {
matches: SearchMatch[];
total: number;
truncated: boolean;
source: 'ring' | 'capture';
}
/**
* Search a captured pane buffer using a regex. This is the fallback path
* when the ring buffer doesn't have enough matches.
*/
function grepBuffer(
text: string,
pattern: string,
limit: number,
context: number,
): SearchMatch[] {
let re: RegExp;
try {
re = new RegExp(pattern, 'u');
} catch {
return [];
}
const lines = text.split('\n');
const results: SearchMatch[] = [];
for (let i = 0; i < lines.length; i++) {
if (results.length >= limit) break;
if (re.test(lines[i]!)) {
const contextBefore: string[] = [];
const contextAfter: string[] = [];
for (let c = 1; c <= context; c++) {
const ci = i - c;
if (ci >= 0) contextBefore.unshift(lines[ci]!);
}
for (let c = 1; c <= context; c++) {
const ci = i + c;
if (ci < lines.length) contextAfter.push(lines[ci]!);
}
results.push({
line: i + 1,
content: lines[i]!,
contextBefore,
contextAfter,
});
}
}
return results;
}
export function registerSearchRoutes(app: FastifyInstance, tmuxConfPath: string): void {
app.get<{
Params: { sid: string; pid: string };
Querystring: { pattern?: string; limit?: string; context?: string };
}>(
'/api/term/sessions/:sid/panes/:pid/search',
async (req, reply) => {
const p = ParamsSchema.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
const sid = sanitizeId(p.data.sid);
const pid = sanitizeId(p.data.pid);
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
const q = QuerySchema.safeParse(req.query);
if (!q.success) {
return reply.code(400).send({
error: 'bad_query',
details: q.error.flatten().fieldErrors,
});
}
const { pattern, limit, context } = q.data;
// ── Path 1: ring buffer search (fast, no tmux interaction) ──
const ringMatches = searchRingBuffer(pid, pattern, { limit, context });
if (ringMatches.length >= limit) {
return reply.code(200).send({
matches: ringMatches,
total: ringMatches.length,
truncated: ringMatches.length >= limit,
source: 'ring' as const,
});
}
// ── Path 2: capture-pane + grep fallback (10s timeout) ──
const sessionName = tmuxSessionName(pid);
let capture: string;
try {
capture = await withTimeout(
capturePane(tmuxConfPath, sessionName, 5000),
10_000,
);
} catch (err) {
req.log.warn({ err, pid }, 'capture-pane timed out or failed');
return reply.code(200).send({
matches: ringMatches,
total: ringMatches.length,
truncated: false,
source: 'ring' as const,
});
}
if (!capture) {
// tmux pane may no longer exist — return whatever ring had
return reply.code(200).send({
matches: ringMatches,
total: ringMatches.length,
truncated: false,
source: 'ring' as const,
});
}
const captureMatches = grepBuffer(capture, pattern, limit, context);
return reply.code(200).send({
matches: captureMatches,
total: captureMatches.length,
truncated: captureMatches.length >= limit,
source: 'capture' as const,
});
},
);
}
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), ms),
),
]);
}

View File

@@ -9,7 +9,7 @@ import {
} from '../pty/manager.js'; } from '../pty/manager.js';
import { attachPty } from '../pty/pty.js'; import { attachPty } from '../pty/pty.js';
import { getUser } from '../auth.js'; import { getUser } from '../auth.js';
import { register, unregister } from '../pty/registry.js'; import { register, unregister, appendOutput } from '../pty/registry.js';
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void { export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
app.get<{ app.get<{
@@ -106,6 +106,8 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
} catch (err) { } catch (err) {
req.log.warn({ err }, 'ws send failed'); req.log.warn({ err }, 'ws send failed');
} }
// Feed the ring buffer for pattern-based search
appendOutput(pid, data);
}; };
handle.onData(onData); handle.onData(onData);

View File

@@ -38,10 +38,31 @@ export interface StepContext {
readonly model?: string; readonly model?: string;
} }
export type StepKind = 'agent' | 'code' | 'approval'; export type StepKind = 'agent' | 'code' | 'approval' | 'switch';
/**
* One branch of a SWITCH step. The first case whose condition evaluates to true
* is selected; all other branches' stepIds are excluded from execution.
*/
export interface SwitchCase {
/** Human-readable label for this branch (reported in switch output). */
label: string;
/** Pure guard — called with the current step context to decide this branch. */
condition: (ctx: StepContext) => boolean;
/** stepIds belonging to this branch. */
stepIds: string[];
}
export type TriggerRule = 'all_success' | 'one_success' | 'all_done'; export type TriggerRule = 'all_success' | 'one_success' | 'all_done';
/** Possible statuses for a flow step (persisted in flow_steps.status). */
export type StepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'timed_out';
/** Retry policy for a step that times out. */
export interface RetryConfig {
maxRetries: number;
}
export interface Step { export interface Step {
/** unique id within the flow; other steps depend on it by this id */ /** unique id within the flow; other steps depend on it by this id */
id: string; id: string;
@@ -55,10 +76,19 @@ export interface Step {
/** /**
* For kind:'agent', returns the worker PROMPT (task + any prior outputs). * For kind:'agent', returns the worker PROMPT (task + any prior outputs).
* For kind:'code', returns the step RESULT directly (the fold/transform). * For kind:'code', returns the step RESULT directly (the fold/transform).
* For kind:'switch', unused (the runner evaluates cases internally).
*/ */
run: (ctx: StepContext) => string | Promise<string>; run: (ctx: StepContext) => string | Promise<string>;
/** optional guard — when it returns false the step is skipped (e.g. no repo) */ /** optional guard — when it returns false the step is skipped (e.g. no repo) */
when?: (ctx: StepContext) => boolean; when?: (ctx: StepContext) => boolean;
/** max retries on timeout (0 or unset = no retry) */
maxRetries?: number;
/** batch group id; steps sharing the same batch are gated by batchConfig.maxConcurrent */
batch?: string;
/** for kind:'switch' — ordered list of branches evaluated in declaration order */
cases?: SwitchCase[];
/** for kind:'switch' — fallback step ids when no case matches */
defaultBranch?: string[];
} }
export interface Flow { export interface Flow {
@@ -69,6 +99,8 @@ export interface Flow {
render: (ctx: StepContext) => string; render: (ctx: StepContext) => string;
/** optional output filename for the artifact, derived from input */ /** optional output filename for the artifact, derived from input */
output?: (ctx: StepContext) => string; output?: (ctx: StepContext) => string;
/** batch parallelism control — gates concurrent dispatch of steps sharing the same batch id */
batchConfig?: { maxConcurrent: number; timeoutMs?: number; joinRule?: TriggerRule };
} }
export interface RunResult { export interface RunResult {

View File

@@ -52,6 +52,9 @@ const ConfigSchema = z.object({
ORPHAN_WORKTREE_GRACE_MS: z.coerce.number().int().positive().default(3_600_000), ORPHAN_WORKTREE_GRACE_MS: z.coerce.number().int().positive().default(3_600_000),
DEEPSEEK_API_KEY: z.string().optional(), DEEPSEEK_API_KEY: z.string().optional(),
DEEPSEEK_BASE_URL: z.string().url().default('https://api.deepseek.com'), DEEPSEEK_BASE_URL: z.string().url().default('https://api.deepseek.com'),
// v2.9.x: flow step timeout (default 5 min). When a 'running' step exceeds
// this duration, it is marked 'timed_out' and may be retried.
FLOW_STEP_TIMEOUT_MS: z.coerce.number().int().positive().default(300_000),
}); });
export type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -266,7 +266,7 @@ CREATE INDEX IF NOT EXISTS claude_session_entries_key_idx ON claude_session_entr
-- replaces it with the three-value list). -- replaces it with the three-value list).
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_backend_chk; ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_backend_chk;
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_backend_chk ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_backend_chk
CHECK (backend IN ('opencode_server', 'acp_warm', 'claude_sdk')); CHECK (backend IN ('opencode_server', 'acp_warm', 'claude_sdk', 'paseo'));
-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes, -- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
-- new_task tool, MCP server) fires pg_notify('tasks_new') in the same -- new_task tool, MCP server) fires pg_notify('tasks_new') in the same
@@ -340,11 +340,12 @@ CREATE INDEX IF NOT EXISTS flow_steps_task_id_idx ON flow_steps(task_id);
-- edits above are no-ops on the existing DB (CREATE TABLE IF NOT EXISTS skips an -- edits above are no-ops on the existing DB (CREATE TABLE IF NOT EXISTS skips an
-- existing table) — widen via the repo's DROP-IF-EXISTS → guarded-ADD discipline. -- existing table) — widen via the repo's DROP-IF-EXISTS → guarded-ADD discipline.
-- Pure ADD of a new allowed value, so no row UPDATE is needed (no value renamed). -- Pure ADD of a new allowed value, so no row UPDATE is needed (no value renamed).
-- v2.9.x: widen status CHECKs to include 'timed_out' for Task State Machine.
ALTER TABLE flow_runs DROP CONSTRAINT IF EXISTS flow_runs_status_chk; ALTER TABLE flow_runs DROP CONSTRAINT IF EXISTS flow_runs_status_chk;
DO $$ BEGIN DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'flow_runs_status_chk') THEN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'flow_runs_status_chk') THEN
ALTER TABLE flow_runs ADD CONSTRAINT flow_runs_status_chk ALTER TABLE flow_runs ADD CONSTRAINT flow_runs_status_chk
CHECK (status IN ('running', 'completed', 'failed', 'cancelled')); CHECK (status IN ('running', 'completed', 'failed', 'cancelled', 'timed_out'));
END IF; END IF;
END $$; END $$;
@@ -352,10 +353,14 @@ ALTER TABLE flow_steps DROP CONSTRAINT IF EXISTS flow_steps_status_chk;
DO $$ BEGIN DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'flow_steps_status_chk') THEN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'flow_steps_status_chk') THEN
ALTER TABLE flow_steps ADD CONSTRAINT flow_steps_status_chk ALTER TABLE flow_steps ADD CONSTRAINT flow_steps_status_chk
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped', 'cancelled')); CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped', 'cancelled', 'timed_out'));
END IF; END IF;
END $$; END $$;
-- Task State Machine: retry columns for flow_steps.
ALTER TABLE flow_steps ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE flow_steps ADD COLUMN IF NOT EXISTS max_retries INTEGER;
-- Arena: battles + contestants + cross_examinations. -- Arena: battles + contestants + cross_examinations.
-- project_id carries no FK (matches tasks.project_id + flow_runs.project_id convention). -- project_id carries no FK (matches tasks.project_id + flow_runs.project_id convention).
-- winner_contestant_id FK is deferred (forward reference): added via guarded ALTER below. -- winner_contestant_id FK is deferred (forward reference): added via guarded ALTER below.

View File

@@ -1,16 +1,20 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { Flow, Step, StepContext } from '../../conductor/types.js'; import type { Flow, Step, StepContext } from '../../conductor/types.js';
import { import {
buildBatchState,
getReadyInBatch,
manifestSteps, manifestSteps,
readySteps,
partitionReady, partitionReady,
readySteps,
isRunComplete, isRunComplete,
isStuck, isStuck,
reconcileResumeStep, reconcileResumeStep,
reconcileRun, reconcileRun,
resolveSwitch,
shouldFailOnMissingAgent, shouldFailOnMissingAgent,
type SchedulerState, type SchedulerState,
} from '../flow-runner-decisions.js'; } from '../flow-runner-decisions.js';
import type { StepContext } from '../../conductor/types.js';
/** /**
* The DB-driven flow-runner replaces the Phase-1 in-memory wave scheduler * The DB-driven flow-runner replaces the Phase-1 in-memory wave scheduler
@@ -52,6 +56,8 @@ const emptyState = (over: Partial<SchedulerState> = {}): SchedulerState => ({
skipped: new Set(), skipped: new Set(),
inFlight: new Set(), inFlight: new Set(),
excluded: new Set(), excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
...over, ...over,
}); });
@@ -237,6 +243,442 @@ describe('isRunComplete / isStuck', () => {
}); });
}); });
// ─── SWITCH branching (v2.9) ─────────────────────────────────────────────────
describe('resolveSwitch', () => {
const baseCtx: StepContext = { input: { question: 'q', band: 'small' }, results: {} };
it('selects the first matching case and excludes other branches', () => {
const step: Step = {
id: 'router',
kind: 'switch',
run: () => '',
cases: [
{ label: 'a', condition: () => false, stepIds: ['a1', 'a2'] },
{ label: 'b', condition: () => true, stepIds: ['b1', 'b2'] },
{ label: 'c', condition: () => true, stepIds: ['c1', 'c2'] },
],
};
const result = resolveSwitch(step, baseCtx);
expect(result.chosenCase).toBe('b');
expect(result.excluded).toEqual(['a1', 'a2', 'c1', 'c2']);
});
it('falls back to defaultBranch when no case matches', () => {
const step: Step = {
id: 'router',
kind: 'switch',
run: () => '',
cases: [
{ label: 'x', condition: () => false, stepIds: ['x1'] },
{ label: 'y', condition: () => false, stepIds: ['y1'] },
],
defaultBranch: ['z1', 'z2'],
};
const result = resolveSwitch(step, baseCtx);
expect(result.chosenCase).toBeNull();
// Only case branch steps are excluded; default steps are not.
expect(result.excluded).toEqual(['x1', 'y1']);
});
it('excludes all branch steps when no case matches and no default', () => {
const step: Step = {
id: 'router',
kind: 'switch',
run: () => '',
cases: [
{ label: 'p', condition: () => false, stepIds: ['p1'] },
{ label: 'q', condition: () => false, stepIds: ['q1', 'q2'] },
],
};
const result = resolveSwitch(step, baseCtx);
expect(result.chosenCase).toBeNull();
expect(result.excluded).toEqual(['p1', 'q1', 'q2']);
});
it('excludes defaultBranch when a case matched', () => {
const step: Step = {
id: 'router',
kind: 'switch',
run: () => '',
cases: [
{ label: 'hit', condition: () => true, stepIds: ['h1'] },
{ label: 'miss', condition: () => false, stepIds: ['m1'] },
],
defaultBranch: ['d1'],
};
const result = resolveSwitch(step, baseCtx);
expect(result.chosenCase).toBe('hit');
expect(result.excluded).toEqual(['m1', 'd1']);
});
it('returns empty excluded for a degenerate switch with no cases and no default', () => {
const step: Step = {
id: 'noop',
kind: 'switch',
run: () => '',
};
const result = resolveSwitch(step, baseCtx);
expect(result.chosenCase).toBeNull();
expect(result.excluded).toEqual([]);
});
it('uses ctx.results in condition evaluation', () => {
const step: Step = {
id: 'router',
kind: 'switch',
run: () => '',
cases: [
{ label: 'has', condition: (ctx) => ctx.results['prev'] === 'yes', stepIds: ['yes-branch'] },
{ label: 'no', condition: () => true, stepIds: ['no-branch'] },
],
};
const ctxWithResult: StepContext = { input: { question: 'q', band: 'small' }, results: { prev: 'yes' } };
const result = resolveSwitch(step, ctxWithResult);
expect(result.chosenCase).toBe('has');
expect(result.excluded).toEqual(['no-branch']);
});
});
describe('readySteps with switch-excluded steps', () => {
// Flow: switch router → branch-a/branch-b → fold
function switchFlow(): Flow {
const steps: Step[] = [
{
id: 'switch', kind: 'switch', run: () => '',
cases: [
{ label: 'a', condition: () => true, stepIds: ['branch-a'] },
{ label: 'b', condition: () => false, stepIds: ['branch-b'] },
],
},
{ id: 'branch-a', kind: 'agent', agent: 'x', deps: ['switch'], run: () => 'p' },
{ id: 'branch-b', kind: 'agent', agent: 'y', deps: ['switch'], run: () => 'q' },
{ id: 'fold', kind: 'code', deps: ['branch-a', 'branch-b'], run: () => 'r' },
];
return { name: 'switch-demo', description: '', steps, render: () => '' };
}
it('excludes non-selected branch steps and treats them as satisfied deps', () => {
const flow = switchFlow();
// switch completed, branch-b excluded by switch (branch-a selected)
const switchResult = new Map<string, { chosenCase: string | null; excluded: Set<string> }>([
['switch', { chosenCase: 'a', excluded: new Set(['branch-b']) }],
]);
const state: SchedulerState = {
done: new Set(['switch']),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: switchResult,
};
const ready = readySteps(flow, state).map((s) => s.id);
// branch-a is ready (dep switch is done), branch-b is excluded
expect(ready).toContain('branch-a');
expect(ready).not.toContain('branch-b');
});
it('fold unblocks once selected branch completes (excluded branch satisfied)', () => {
const flow = switchFlow();
const switchResult = new Map<string, { chosenCase: string | null; excluded: Set<string> }>([
['switch', { chosenCase: 'a', excluded: new Set(['branch-b']) }],
]);
const state: SchedulerState = {
done: new Set(['switch', 'branch-a']),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: switchResult,
};
const ready = readySteps(flow, state).map((s) => s.id);
// fold's deps: branch-a done, branch-b excluded (via switch) → satisfied
expect(ready).toContain('fold');
});
it('fold stays blocked until selected branch completes, even with excluded dep', () => {
const flow = switchFlow();
const switchResult = new Map<string, { chosenCase: string | null; excluded: Set<string> }>([
['switch', { chosenCase: 'a', excluded: new Set(['branch-b']) }],
]);
const state: SchedulerState = {
done: new Set(['switch']),
skipped: new Set(),
inFlight: new Set(['branch-a']),
excluded: new Set(),
timedOut: new Set(),
switchResults: switchResult,
};
const ready = readySteps(flow, state).map((s) => s.id);
// branch-a in flight, branch-b excluded — only branch-a offered
expect(ready).not.toContain('fold');
});
it('isRunComplete returns true when switch-excluded steps are the only unsettled', () => {
const flow = switchFlow();
// All non-excluded steps done; branch-b is excluded via switch
const switchResult = new Map<string, { chosenCase: string | null; excluded: Set<string> }>([
['switch', { chosenCase: 'a', excluded: new Set(['branch-b']) }],
]);
const state: SchedulerState = {
done: new Set(['switch', 'branch-a', 'fold']),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: switchResult,
};
expect(isRunComplete(flow, state)).toBe(true);
expect(isStuck(flow, state)).toBe(false);
});
it('combines static excluded with switch-excluded', () => {
const flow = switchFlow();
// band gating excludes branch-b at launch, AND switch also excludes it
const switchResult = new Map<string, { chosenCase: string | null; excluded: Set<string> }>([
['switch', { chosenCase: 'a', excluded: new Set(['branch-b']) }],
]);
const state: SchedulerState = {
done: new Set(['switch', 'branch-a']),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(['branch-b']),
timedOut: new Set(),
switchResults: switchResult,
};
// branch-b excluded both ways; fold sees branch-a done, branch-b excluded
const ready = readySteps(flow, state).map((s) => s.id);
expect(ready).toContain('fold');
});
});
// ─── Batch parallelism (v2.8.22) ─────────────────────────────────────────────
describe('buildBatchState', () => {
it('returns empty map when flow has no batchConfig', () => {
const flow: Flow = {
name: 'no-batch',
description: '',
steps: [
{ id: 'a', kind: 'agent', agent: 'x', run: () => 'p' },
{ id: 'b', kind: 'code', deps: ['a'], run: () => 'r' },
],
render: () => '',
};
const bs = buildBatchState(flow, new Set());
expect(bs.size).toBe(0);
});
it('maps each batch group to its running set and config', () => {
const flow: Flow = {
name: 'batched',
description: '',
steps: [
{ id: 'a1', kind: 'agent', agent: 'x', batch: 'review', run: () => 'p' },
{ id: 'a2', kind: 'agent', agent: 'y', batch: 'review', run: () => 'q' },
{ id: 'b1', kind: 'agent', agent: 'z', batch: 'check', run: () => 'r' },
{ id: 'fold', kind: 'code', deps: ['a1', 'a2', 'b1'], run: () => 's' },
],
render: () => '',
batchConfig: { maxConcurrent: 2 },
};
// a1 is in flight → review batch has 1 running, check has 0.
const bs = buildBatchState(flow, new Set(['a1']));
expect(bs.size).toBe(2);
const review = bs.get('review');
expect(review).toBeDefined();
expect([...review!.running]).toEqual(['a1']);
expect(review!.maxConcurrent).toBe(2);
expect(review!.joinRule).toBe('all_success');
const check = bs.get('check');
expect(check).toBeDefined();
expect(check!.running.size).toBe(0);
expect(check!.maxConcurrent).toBe(2);
});
it('uses joinRule from batchConfig when provided', () => {
const flow: Flow = {
name: 'join',
description: '',
steps: [
{ id: 'x', kind: 'agent', agent: 'a', batch: 'g1', run: () => 'p' },
],
render: () => '',
batchConfig: { maxConcurrent: 1, joinRule: 'one_success' },
};
const bs = buildBatchState(flow, new Set());
expect(bs.get('g1')!.joinRule).toBe('one_success');
});
it('ignores steps without a batch field', () => {
const flow: Flow = {
name: 'mixed',
description: '',
steps: [
{ id: 'a', kind: 'agent', agent: 'x', run: () => 'p' },
{ id: 'b', kind: 'agent', agent: 'y', batch: 'g1', run: () => 'q' },
],
render: () => '',
batchConfig: { maxConcurrent: 3 },
};
const bs = buildBatchState(flow, new Set(['a', 'b']));
// a is inFlight but has no batch — it does not create an entry
expect(bs.size).toBe(1);
expect(bs.has('g1')).toBe(true);
expect(bs.get('g1')!.running.has('b')).toBe(true);
// a is not in any batch entry
for (const entry of bs.values()) {
expect(entry.running.has('a')).toBe(false);
}
});
});
describe('getReadyInBatch', () => {
function makeBatchState(
overrides?: Map<string, { running: Set<string>; maxConcurrent: number; joinRule: TriggerRule }>,
): Map<string, { running: Set<string>; maxConcurrent: number; joinRule: TriggerRule }> {
return overrides ?? new Map();
}
it('passes all steps through when batchState is empty', () => {
const steps: Step[] = [
{ id: 'a', kind: 'agent', agent: 'x', run: () => 'p' },
{ id: 'b', kind: 'agent', agent: 'y', batch: 'g1', run: () => 'q' },
];
const state: SchedulerState = {
done: new Set(),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
batchState: makeBatchState(),
};
const result = getReadyInBatch(steps, state, {} as Flow);
expect(result.map((s) => s.id)).toEqual(['a', 'b']);
});
it('passes non-batched steps through regardless of batch capacity', () => {
const batchState = new Map();
batchState.set('g1', { running: new Set(['a']), maxConcurrent: 1, joinRule: 'all_success' });
const steps: Step[] = [
{ id: 'nobatch', kind: 'agent', agent: 'z', run: () => 'r' },
{ id: 'batched', kind: 'agent', agent: 'x', batch: 'g1', run: () => 'p' },
];
const state: SchedulerState = {
done: new Set(),
skipped: new Set(),
inFlight: new Set(['a']),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
batchState,
};
const result = getReadyInBatch(steps, state, {} as Flow);
// nobatch passes, batched is at maxConcurrent=1 with a already running → blocked
expect(result.map((s) => s.id)).toEqual(['nobatch']);
});
it('allows batch steps up to maxConcurrent', () => {
const batchState = new Map();
batchState.set('g1', { running: new Set(), maxConcurrent: 2, joinRule: 'all_success' });
const steps: Step[] = [
{ id: 's1', kind: 'agent', agent: 'x', batch: 'g1', run: () => 'p' },
{ id: 's2', kind: 'agent', agent: 'y', batch: 'g1', run: () => 'q' },
{ id: 's3', kind: 'agent', agent: 'z', batch: 'g1', run: () => 'r' },
];
const state: SchedulerState = {
done: new Set(),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
batchState,
};
// All 0 running, maxConcurrent=2 → all 3 pass through (readySteps would return them,
// but the flow-runner dispatches them one-by-one in the agent dispatch loop; getReadyInBatch
// is called each tick to allow up to maxConcurrent. Since batch is empty on this tick,
// all are allowed — the runner's dispatch loop will put 2 in flight, then next tick blocks.)
const result = getReadyInBatch(steps, state, {} as Flow);
expect(result.map((s) => s.id)).toEqual(['s1', 's2', 's3']);
});
it('blocks batch steps when at capacity', () => {
const batchState = new Map();
batchState.set('g1', { running: new Set(['a', 'b']), maxConcurrent: 2, joinRule: 'all_success' });
const steps: Step[] = [
{ id: 'c', kind: 'agent', agent: 'x', batch: 'g1', run: () => 'p' },
{ id: 'd', kind: 'agent', agent: 'y', batch: 'g1', run: () => 'q' },
];
const state: SchedulerState = {
done: new Set(),
skipped: new Set(),
inFlight: new Set(['a', 'b']),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
batchState,
};
// Both batches at capacity → everything filtered out
expect(getReadyInBatch(steps, state, {} as Flow)).toEqual([]);
});
it('handles multiple independent batch groups', () => {
const batchState = new Map();
batchState.set('g1', { running: new Set(['a']), maxConcurrent: 1, joinRule: 'all_success' });
batchState.set('g2', { running: new Set(), maxConcurrent: 5, joinRule: 'all_success' });
const steps: Step[] = [
{ id: 'b', kind: 'agent', agent: 'x', batch: 'g1', run: () => 'p' }, // g1 at capacity → blocked
{ id: 'c', kind: 'agent', agent: 'y', batch: 'g2', run: () => 'q' }, // g2 has room → passes
{ id: 'd', kind: 'agent', agent: 'z', batch: 'g2', run: () => 'r' }, // g2 has room → passes
];
const state: SchedulerState = {
done: new Set(),
skipped: new Set(),
inFlight: new Set(['a']),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
batchState,
};
expect(getReadyInBatch(steps, state, {} as Flow).map((s) => s.id)).toEqual(['c', 'd']);
});
it('lets a step pass when its batch group is known but has no running steps yet', () => {
const batchState = new Map();
batchState.set('g1', { running: new Set(), maxConcurrent: 2, joinRule: 'all_success' });
const steps: Step[] = [
{ id: 'first', kind: 'agent', agent: 'x', batch: 'g1', run: () => 'p' },
];
const state: SchedulerState = {
done: new Set(),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
batchState,
};
expect(getReadyInBatch(steps, state, {} as Flow).map((s) => s.id)).toEqual(['first']);
});
it('handles empty step list gracefully', () => {
const state: SchedulerState = {
done: new Set(),
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
batchState: makeBatchState(),
};
expect(getReadyInBatch([], state, {} as Flow)).toEqual([]);
});
});
// ─── Resume reconciliation (D-9) ───────────────────────────────────────────── // ─── Resume reconciliation (D-9) ─────────────────────────────────────────────
describe('reconcileResumeStep', () => { describe('reconcileResumeStep', () => {

View File

@@ -0,0 +1,195 @@
import { describe, it, expect, vi } from 'vitest';
import { PaseoClient, PaseoClientError } from '../paseo-client.js';
/**
* Create a PaseoClient whose runCli method is replaced with a mock.
* The mock is returned as the second tuple element so tests can
* control and inspect it directly.
*/
function makeClient(config?: { paseoBin?: string; cliHost?: string }): {
client: PaseoClient;
mockRunCli: ReturnType<typeof vi.fn>;
} {
const client = new PaseoClient(config);
const mockRunCli = vi.fn();
(client as any).runCli = mockRunCli;
return { client, mockRunCli };
}
describe('PaseoClient', () => {
describe('listAgents', () => {
it('returns parsed agent list from paseo ls --json', async () => {
const agents = [
{ id: 'abc-123', shortId: 'abc', name: 'Agent 1', provider: 'opencode', status: 'running' },
{ id: 'def-456', shortId: 'def', name: 'Agent 2', provider: 'claude', status: 'idle' },
];
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue(JSON.stringify(agents));
const result = await client.listAgents();
expect(mockRunCli).toHaveBeenCalledWith(['ls', '--json']);
expect(result).toEqual(agents);
});
it('throws PaseoClientError on non-JSON output', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue('not json');
await expect(client.listAgents()).rejects.toThrow(PaseoClientError);
await expect(client.listAgents()).rejects.toThrow(/invalid JSON/);
});
it('propagates runCli rejection as-is', async () => {
const { client, mockRunCli } = makeClient();
const err = new PaseoClientError('ls failed: connection refused', 'ls', 1, 'connection refused');
mockRunCli.mockRejectedValue(err);
await expect(client.listAgents()).rejects.toThrow(PaseoClientError);
await expect(client.listAgents()).rejects.toThrow(/ls failed/);
});
});
describe('getAgentStatus', () => {
it('returns parsed agent detail from paseo inspect --json', async () => {
const detail = {
Id: 'abc-123', Name: 'Agent 1', Provider: 'opencode',
Status: 'idle', Archived: false,
CreatedAt: '2026-01-01T00:00:00Z', UpdatedAt: '2026-01-01T01:00:00Z',
};
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue(JSON.stringify(detail));
const result = await client.getAgentStatus('abc-123');
expect(mockRunCli).toHaveBeenCalledWith(['inspect', '--json', 'abc-123']);
expect(result.Id).toBe('abc-123');
expect(result.Status).toBe('idle');
});
});
describe('health', () => {
it('returns ok when paseo ls succeeds', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue('[]');
const result = await client.health();
expect(result).toEqual({ status: 'ok' });
});
it('returns error when runCli throws', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockRejectedValue(new Error('connection refused'));
const result = await client.health();
expect(result).toEqual({ status: 'error' });
});
});
describe('importAgent', () => {
it('calls paseo import with provider and labels', async () => {
const agentResult = { Id: 'new-789', Name: 'Imported', Provider: 'opencode', Status: 'idle' };
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue(JSON.stringify(agentResult));
const result = await client.importAgent('ses-001', 'opencode', {
origin: 'boocode',
project: 'proj-1',
});
expect(mockRunCli).toHaveBeenCalledWith([
'import', '--json',
'--provider', 'opencode',
'--label', 'origin=boocode',
'--label', 'project=proj-1',
'ses-001',
]);
expect(result.Id).toBe('new-789');
});
it('works without labels', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue(JSON.stringify({ Id: 'new-789' }));
const result = await client.importAgent('ses-001', 'claude');
expect(mockRunCli).toHaveBeenCalledWith([
'import', '--json',
'--provider', 'claude',
'ses-001',
]);
expect(result.Id).toBe('new-789');
});
});
describe('archiveAgent', () => {
it('calls paseo archive --json', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue('{}');
await client.archiveAgent('abc-123');
expect(mockRunCli).toHaveBeenCalledWith(['archive', '--json', 'abc-123']);
});
});
describe('sendPrompt', () => {
it('sends prompt and parses JSON result', async () => {
const sendResult = { text: 'Hello!', ok: true };
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue(JSON.stringify(sendResult));
const result = await client.sendPrompt('abc-123', 'Hello');
expect(mockRunCli).toHaveBeenCalledWith(['send', '--json', 'abc-123', 'Hello'], undefined);
expect(result).toEqual(sendResult);
});
it('falls back to plain text on non-JSON output', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue('plain text response');
const result = await client.sendPrompt('abc-123', 'Hi');
expect(result).toEqual({ text: 'plain text response', ok: true });
});
it('supports --no-wait flag', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue('{}');
await client.sendPrompt('abc-123', 'Hi', { noWait: true });
expect(mockRunCli).toHaveBeenCalledWith([
'send', '--json', '--no-wait',
'abc-123', 'Hi',
], undefined);
});
});
describe('stopAgent', () => {
it('calls paseo stop', async () => {
const { client, mockRunCli } = makeClient();
mockRunCli.mockResolvedValue('');
await client.stopAgent('abc-123');
expect(mockRunCli).toHaveBeenCalledWith(['stop', 'abc-123']);
});
});
describe('cliHost config', () => {
it('includes --host flag in args when cliHost is set', async () => {
const { client, mockRunCli } = makeClient({ cliHost: 'tcp://localhost:6767?ssl=true' });
mockRunCli.mockResolvedValue('[]');
await client.listAgents();
expect(mockRunCli).toHaveBeenCalledWith([
'ls', '--json', '--host', 'tcp://localhost:6767?ssl=true',
]);
});
});
});

View File

@@ -13,7 +13,7 @@ import type { AcpToolSnapshot } from './acp-tool-snapshot.js';
import type { AgentCommand } from './provider-types.js'; import type { AgentCommand } from './provider-types.js';
/** Backend transport kind. Mirrors `agent_sessions.backend` CHECK in schema.sql. */ /** Backend transport kind. Mirrors `agent_sessions.backend` CHECK in schema.sql. */
export type AgentBackendKind = 'opencode_server' | 'acp_warm' | 'claude_sdk'; export type AgentBackendKind = 'opencode_server' | 'acp_warm' | 'claude_sdk' | 'paseo';
/** /**
* Normalized, transport-agnostic events a backend emits during a turn (§2). * Normalized, transport-agnostic events a backend emits during a turn (§2).

View File

@@ -0,0 +1,254 @@
/**
* v2.10 — PaseoBackend: Paseo agent integration for the agent-pool.
*
* Wraps the Paseo CLI daemon as an AgentBackend. Each Paseo agent maps to one
* (chat_id, agent) pair and is persisted via `paseo import` (which registers
* an agent with the Paseo daemon). Prompts are sent via `paseo send`, and
* the session is cleaned up via `paseo archive`.
*
* Paseo is a meta-agent hub — it wraps provider sessions (opencode, claude,
* acp, etc.). The `provider` option in `EnsureSessionOpts` selects which
* provider Paseo delegates to.
*
* Backend kind: 'paseo' (must be added to agent_sessions_backend_chk).
*
* Spec: openspec/changes/v2-10-paseo-integration/design.md.
*/
import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../../db.js';
import { PaseoClient, type PaseoSendResult } from '../paseo-client.js';
import type {
AgentBackend,
AgentSessionHandle,
EnsureSessionOpts,
PromptCtx,
TurnResult,
} from '../agent-backend.js';
/** Default provider to use when Paseo wraps a generic agent. */
const DEFAULT_PASEO_PROVIDER = 'opencode';
export interface PaseoBackendDeps {
sql: Sql;
log: FastifyBaseLogger;
/** The (chat, agent) this backend serves — its pool identity + DB key. */
chatId: string;
/** Agent name (e.g. 'opencode', 'claude', 'paseo'). */
agent: string;
/** Resolved PaseoClient instance. */
client: PaseoClient;
/** Provider string to pass to `paseo import --provider`. */
provider: string;
}
export class PaseoBackend implements AgentBackend {
readonly backend = 'paseo' as const;
private readonly sql: Sql;
private readonly log: FastifyBaseLogger;
private readonly chatId: string;
private readonly agent: string;
private readonly client: PaseoClient;
private readonly provider: string;
/** Map of BooCode sessionId → Paseo agent ID. */
private readonly agentIds = new Map<string, string>();
/** True between prompt() start and settle. */
private busy = false;
private up = false;
constructor(deps: PaseoBackendDeps) {
this.sql = deps.sql;
this.log = deps.log;
this.chatId = deps.chatId;
this.agent = deps.agent;
this.client = deps.client;
this.provider = deps.provider || DEFAULT_PASEO_PROVIDER;
}
/** §2: liveness for the health endpoint + dispatcher fallback decision. */
health(): 'up' | 'down' {
return this.up ? 'up' : 'down';
}
/** Phase 3: busy iff a turn is in flight (pool never evicts a busy backend). */
isBusy(): boolean {
return this.busy;
}
// ─── ensureSession: create/import a Paseo agent ─────────────────────────────
async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> {
// Check if we already have a Paseo agent ID for this session.
let paseoId = this.agentIds.get(sessionId);
if (!paseoId) {
// Resolve existing agent_session_id from DB (e.g. after a restart).
const [row] = await this.sql<{ agent_session_id: string | null }[]>`
SELECT agent_session_id FROM agent_sessions
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent} AND backend = 'paseo'
`;
if (row?.agent_session_id) {
paseoId = row.agent_session_id;
this.agentIds.set(sessionId, paseoId);
}
}
if (!paseoId) {
// Import a new Paseo agent. Use the session UUID as the provider session id.
const labels: Record<string, string> = {
origin: 'boocode',
project: opts.projectId,
chat: opts.chatId,
worktree: opts.worktreeId,
agent: this.agent,
};
try {
const agent = await this.client.importAgent(sessionId, this.provider, labels);
paseoId = agent.Id;
this.agentIds.set(sessionId, paseoId);
this.log.info(
{ paseoId, agent: this.agent, chatId: this.chatId },
'paseo: imported agent',
);
} catch (err) {
this.log.error(
{ err: String(err), agent: this.agent, chatId: this.chatId },
'paseo: importAgent failed',
);
throw err;
}
}
// Upsert the agent_sessions row.
await this.sql`
INSERT INTO agent_sessions
(chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at)
VALUES
(${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'paseo', ${paseoId}, NULL, 'active', clock_timestamp())
ON CONFLICT (chat_id, agent) DO UPDATE SET
session_id = EXCLUDED.session_id,
worktree_id = EXCLUDED.worktree_id,
backend = 'paseo',
agent_session_id = COALESCE(EXCLUDED.agent_session_id, agent_sessions.agent_session_id),
server_port = NULL,
status = 'active',
last_active_at = clock_timestamp()
`.catch((err) => {
this.log.warn(
{ err: String(err), chatId: opts.chatId, agent: opts.agent },
'paseo: agent_sessions upsert failed (non-fatal)',
);
});
this.up = true;
return {
sessionId,
agent: opts.agent,
backend: 'paseo',
chatId: opts.chatId,
worktreeId: opts.worktreeId,
agentSessionId: paseoId,
serverPort: null,
};
}
// ─── prompt: send a message to the Paseo agent ─────────────────────────────
async prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult> {
const paseoId = handle.agentSessionId;
if (!paseoId) {
return { ok: false, error: 'paseo: no agent session id in handle' };
}
this.busy = true;
try {
// Use streamSend for real-time text output via onEvent.
const result: PaseoSendResult = await this.client.streamSend(
paseoId,
input,
(event) => {
ctx.onEvent(event);
},
ctx.signal,
);
// Update last_active_at.
await this.sql`
UPDATE agent_sessions
SET last_active_at = clock_timestamp()
WHERE chat_id = ${handle.chatId} AND agent = ${handle.agent}
`.catch(() => { /* non-fatal */ });
if (result.error) {
return { ok: false, error: result.error };
}
return { ok: true };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// Check if abortion
if (ctx.signal.aborted) {
return { ok: false, error: 'cancelled' };
}
return { ok: false, error: `paseo: ${msg}` };
} finally {
this.busy = false;
}
}
// ─── closeSession: archive the Paseo agent ─────────────────────────────────
async closeSession(handle: AgentSessionHandle): Promise<void> {
const paseoId = handle.agentSessionId;
if (!paseoId) return;
try {
await this.client.archiveAgent(paseoId);
this.log.info({ paseoId, agent: handle.agent }, 'paseo: archived agent');
} catch (err) {
this.log.warn(
{ err: String(err), paseoId, agent: handle.agent },
'paseo: archiveAgent failed (non-fatal)',
);
}
this.agentIds.delete(handle.sessionId);
// Update DB row.
await this.sql`
UPDATE agent_sessions
SET status = 'closed', last_active_at = clock_timestamp()
WHERE chat_id = ${handle.chatId} AND agent = ${handle.agent}
`.catch(() => { /* non-fatal */ });
}
// ─── dispose: archive all tracked agents ───────────────────────────────────
async dispose(): Promise<void> {
const ids = [...this.agentIds.values()];
this.agentIds.clear();
for (const paseoId of ids) {
try {
await this.client.archiveAgent(paseoId);
} catch {
// Best-effort cleanup during shutdown.
}
}
this.up = false;
}
/** Phase 3: periodic health tick — probes the Paseo daemon. */
async tickHealth(_now?: number): Promise<void> {
try {
const h = await this.client.health();
this.up = h.status === 'ok';
} catch {
this.up = false;
}
}
}

View File

@@ -0,0 +1,115 @@
// v2.8 Collision detection — pure functions that find file overlaps between
// worktrees/agents editing the same files concurrently. Advisory only; writes
// are never blocked, but the collision info surfaces in the UI and logs.
//
// Severity levels:
// same_line — the same file, exact same line region
// adjacent_line — the same file, lines touch or are within 5 lines
// different_area — the same file, distant lines
//
// Pure functions, no side effects. Testable in isolation.
export type ConflictSeverity = 'same_line' | 'adjacent_line' | 'different_area';
export interface ConflictVerdict {
filePath: string;
worktrees: string[];
severity: ConflictSeverity;
agents: string[];
}
/**
* Registry entry for a single file change recorded by a worktree.
* Stored in the ConflictIndex Map value for each file path.
*/
export interface ConflictEntry {
worktreeId: string;
agent: string;
/**
* Approximate line range touched by the change. undefined when the change
* creates or deletes the file (full-file collision vs. same-line).
*/
lineRange?: { start: number; end: number };
status: 'pending' | 'applied' | 'reverted';
timestamp: number;
}
/**
* Shape of the conflict index consumed by findConflicts.
* File path → set of entries from different worktrees/agents.
*/
export type ConflictIndexData = ReadonlyMap<string, ReadonlySet<ConflictEntry>>;
/**
* Find file overlaps between `changedFiles` and the conflict index, excluding
* the caller's own worktree.
*
* Returns one ConflictVerdict per file that has entries from other worktrees.
* Severity is the highest found (same_line > adjacent_line > different_area).
*/
export function findConflicts(
changedFiles: string[],
worktreeId: string,
/** Approximate line range for the proposed changes, keyed by file path */
changedRanges: Map<string, { start: number; end: number }>,
conflictIndex: ConflictIndexData,
): ConflictVerdict[] {
const verdicts: ConflictVerdict[] = [];
for (const filePath of changedFiles) {
const entries = conflictIndex.get(filePath);
if (!entries || entries.size === 0) continue;
// Filter to entries from OTHER worktrees
const otherEntries = [...entries].filter((e) => e.worktreeId !== worktreeId);
if (otherEntries.length === 0) continue;
const myRange = changedRanges.get(filePath);
let severity: ConflictSeverity = 'different_area';
for (const entry of otherEntries) {
if (!myRange || !entry.lineRange) {
// Full-file changes (create/delete) always hit at least different_area
continue;
}
const sev = lineOverlapSeverity(myRange, entry.lineRange);
if (sev === 'same_line') {
severity = 'same_line';
break; // Can't get higher than this
}
if (sev === 'adjacent_line' && severity === 'different_area') {
severity = 'adjacent_line';
}
}
const worktrees = [...new Set(otherEntries.map((e) => e.worktreeId))];
const agents = [...new Set(otherEntries.map((e) => e.agent))];
verdicts.push({ filePath, worktrees, severity, agents });
}
return verdicts;
}
const ADJACENT_LINE_THRESHOLD = 5;
/**
* Determine severity of overlap between two line ranges.
*/
function lineOverlapSeverity(
a: { start: number; end: number },
b: { start: number; end: number },
): ConflictSeverity {
// Same_line: ranges intersect
if (a.start <= b.end && b.start <= a.end) {
return 'same_line';
}
// Adjacent: ranges are within ADJACENT_LINE_THRESHOLD lines of each other
const gap = a.start > b.end ? a.start - b.end : b.start - a.end;
if (gap <= ADJACENT_LINE_THRESHOLD) {
return 'adjacent_line';
}
return 'different_area';
}

View File

@@ -0,0 +1,151 @@
// v2.8 In-memory conflict index — tracks which worktrees/agents are editing
// which files so the collision detector can find overlaps.
//
// Singleton exported as `conflictIndex`; imported by pending_changes.ts to
// register changes at queue time and unregister on worktree teardown.
//
// NOT persisted — survives only as long as the BooCoder process. Postgres
// is the durable record (pending_changes table); this is the hot in-memory
// probe for concurrent edit warnings.
import type { ConflictEntry, ConflictVerdict } from './collision-detector.js';
import { findConflicts } from './collision-detector.js';
export class ConflictIndex {
/**
* filePath → Set of ConflictEntry from various worktrees.
* A single worktree may have multiple entries for the same file
* (several pending edits to the same file in one session).
*/
#map = new Map<string, Set<ConflictEntry>>();
// ---- mutation -------------------------------------------------------
/**
* Register that `worktreeId` (agent) is touching `filePath`.
* Creates an entry in the index so subsequent callers see it as a conflict.
*/
registerChange(
filePath: string,
worktreeId: string,
agent: string,
lineRange?: { start: number; end: number },
): void {
let entries = this.#map.get(filePath);
if (!entries) {
entries = new Set();
this.#map.set(filePath, entries);
}
entries.add({
worktreeId,
agent,
lineRange,
status: 'pending' as const,
timestamp: Date.now(),
});
}
/**
* Remove all entries for a given worktree. Called on worktree teardown
* so stale entries don't trigger false warnings.
*/
removeWorktree(worktreeId: string): void {
for (const [filePath, entries] of this.#map) {
const before = entries.size;
for (const entry of entries) {
if (entry.worktreeId === worktreeId) {
entries.delete(entry);
}
}
if (entries.size === 0) {
this.#map.delete(filePath);
}
}
}
/**
* Remove entries older than `maxAgeMs`. Useful as a periodic cleanup
* when worktree teardown was missed (crash, unclean exit).
*/
sweepStale(maxAgeMs: number): number {
const cutoff = Date.now() - maxAgeMs;
let removed = 0;
for (const [filePath, entries] of this.#map) {
for (const entry of entries) {
if (entry.timestamp < cutoff) {
entries.delete(entry);
removed++;
}
}
if (entries.size === 0) {
this.#map.delete(filePath);
}
}
return removed;
}
// ---- query ----------------------------------------------------------
/**
* Query the raw ConflictEntry set for a file path. Returns empty set
* when there are no entries (never mutated the file).
*/
getEntriesFor(filePath: string): ReadonlySet<ConflictEntry> {
return this.#map.get(filePath) ?? new Set();
}
/**
* Get all conflict verdicts for a given file path — which other
* worktrees are touching it. Returns empty when only one worktree
* has entries (no actual conflict).
*/
getConflictsFor(filePath: string): ConflictVerdict[] {
const entries = this.#map.get(filePath);
if (!entries || entries.size === 0) return [];
// Determine distinct worktree IDs. If only one, no conflict.
const worktreeIds = new Set<string>();
for (const e of entries) worktreeIds.add(e.worktreeId);
if (worktreeIds.size <= 1) return [];
// Use the first worktree as the "caller" so findConflicts excludes
// its entries and returns only entries from OTHER worktrees.
const caller = [...worktreeIds][0]!;
return findConflicts(
[filePath],
caller,
new Map(),
this.#toIndexData(),
);
}
/**
* Get conflicts for a set of file changes from a specific worktree.
* Delegates to the pure findConflicts function.
*/
query(
changedFiles: string[],
worktreeId: string,
changedRanges: Map<string, { start: number; end: number }>,
): ConflictVerdict[] {
return findConflicts(changedFiles, worktreeId, changedRanges, this.#toIndexData());
}
/**
* Snapshot the current map for testing/inspection.
*/
snapshot(): Map<string, ReadonlySet<ConflictEntry>> {
return new Map(this.#map);
}
// ---- private --------------------------------------------------------
#toIndexData(): ReadonlyMap<string, ReadonlySet<ConflictEntry>> {
return this.#map as ReadonlyMap<string, ReadonlySet<ConflictEntry>>;
}
}
// Singleton — the whole BooCoder process shares one conflict index.
export const conflictIndex = new ConflictIndex();

View File

@@ -33,11 +33,43 @@ export interface SchedulerState {
readonly inFlight: ReadonlySet<string>; readonly inFlight: ReadonlySet<string>;
/** step ids pre-skipped at launch (band/when gating) — never given a row */ /** step ids pre-skipped at launch (band/when gating) — never given a row */
readonly excluded: ReadonlySet<string>; readonly excluded: ReadonlySet<string>;
/** step ids that timed out (terminal — no retries remaining or not retriable) */
readonly timedOut: ReadonlySet<string>;
/**
* Per-batch running sets, populated by buildBatchState from the flow definition
* and the current inFlight set. Only read by getReadyInBatch; never mutated by
* decision functions (the caller maintains it across ticks).
*/
readonly batchState?: Map<string, { running: Set<string>; maxConcurrent: number; joinRule: TriggerRule }>;
/**
* Per-switch-step routing results. Populated when a SWITCH step completes.
* Step ids in any result's `excluded` set are treated as excluded for the
* remainder of the run — they won't execute and won't block dependents.
*/
readonly switchResults: ReadonlyMap<string, { chosenCase: string | null; excluded: ReadonlySet<string> }>;
} }
/** A dependency is satisfied once it is done, skipped, or excluded. */ /** A dependency is satisfied once it is done, skipped, excluded, or timed out. */
function isSatisfied(state: SchedulerState, id: string): boolean { function isSatisfied(state: SchedulerState, id: string): boolean {
return state.done.has(id) || state.skipped.has(id) || state.excluded.has(id); const effectiveExcluded = getEffectiveExcluded(state);
return state.done.has(id) || state.skipped.has(id) || effectiveExcluded.has(id) || state.timedOut.has(id);
}
/**
* The union of the static `excluded` set and every switch result's excluded
* step ids. Steps excluded by a SWITCH evaluation act exactly like launch-time
* excluded steps: they never run and they don't block dependents.
*/
function getEffectiveExcluded(state: SchedulerState): ReadonlySet<string> {
// Fast path: no switch results → static excluded only.
if (state.switchResults.size === 0) return state.excluded;
const combined = new Set(state.excluded);
for (const result of state.switchResults.values()) {
for (const id of result.excluded) {
combined.add(id);
}
}
return combined;
} }
/** /**
@@ -56,13 +88,14 @@ export function manifestSteps(flow: Flow, launchCtx: StepContext): Step[] {
* Faithful to `conductor/flow.ts:27-36`. Pure. * Faithful to `conductor/flow.ts:27-36`. Pure.
*/ */
export function readySteps(flow: Flow, state: SchedulerState): Step[] { export function readySteps(flow: Flow, state: SchedulerState): Step[] {
const effectiveExcluded = getEffectiveExcluded(state);
return flow.steps.filter( return flow.steps.filter(
(s) => (s) =>
!state.done.has(s.id) && !state.done.has(s.id) &&
!state.skipped.has(s.id) && !state.skipped.has(s.id) &&
!state.inFlight.has(s.id) && !state.inFlight.has(s.id) &&
!state.excluded.has(s.id) && !effectiveExcluded.has(s.id) &&
((s.deps ?? []).length === 0 || evaluateTriggerRule(s.deps ?? [], state.done, state.skipped, state.excluded, s.trigger_rule)), ((s.deps ?? []).length === 0 || evaluateTriggerRule(s.deps ?? [], state.done, state.skipped, effectiveExcluded, s.trigger_rule)),
); );
} }
@@ -102,6 +135,57 @@ export function isStuck(flow: Flow, state: SchedulerState): boolean {
); );
} }
// ─── Batch parallelism (v2.8.22) ─────────────────────────────────────────────
/**
* Build the batchState Map from the flow definition and the current inFlight set.
* Only steps with a `batch` field are tracked. Empty map when `flow.batchConfig`
* is absent or no steps belong to a batch. Pure — no IO.
*/
export function buildBatchState(
flow: Flow,
inFlight: ReadonlySet<string>,
): Map<string, { running: Set<string>; maxConcurrent: number; joinRule: TriggerRule }> {
const result = new Map<string, { running: Set<string>; maxConcurrent: number; joinRule: TriggerRule }>();
if (!flow.batchConfig) return result;
// Collect every unique batch group referenced by the flow's steps.
const groups = new Set<string>();
for (const s of flow.steps) {
if (s.batch) groups.add(s.batch);
}
const { maxConcurrent, joinRule } = flow.batchConfig;
for (const batch of groups) {
const running = new Set<string>(
flow.steps.filter((s) => s.batch === batch && inFlight.has(s.id)).map((s) => s.id),
);
result.set(batch, { running, maxConcurrent, joinRule: joinRule ?? 'all_success' });
}
return result;
}
/**
* Gate a ready step list by batch parallelism limits. Steps without a `batch`
* field always pass through. Steps belonging to a batch are only included if
* that batch's currently-running count is below its `maxConcurrent` cap.
*
* This is ADDITIVE to the existing wave scheduler: pure dep-based readiness
* is computed first (readySteps), then this function applies the batch ceiling.
* Steps excluded here remain pending and will be picked up on the next tick
* when a running batch step completes.
*/
export function getReadyInBatch(ready: readonly Step[], state: SchedulerState, _flow: Flow): Step[] {
const batchState = state.batchState;
if (!batchState || batchState.size === 0) return [...ready];
return ready.filter((s) => {
if (!s.batch) return true;
const bs = batchState.get(s.batch);
if (!bs) return true;
return bs.running.size < bs.maxConcurrent;
});
}
// ─── Resume reconciliation (D-9) ───────────────────────────────────────────── // ─── Resume reconciliation (D-9) ─────────────────────────────────────────────
/** /**
@@ -118,12 +202,29 @@ export function isStuck(flow: Flow, state: SchedulerState): boolean {
* - 'mark-cancelled': task was cancelled before the callback ran; propagate so * - 'mark-cancelled': task was cancelled before the callback ran; propagate so
* advance() cancels the run. * advance() cancels the run.
*/ */
/**
* True when the step definition allows retries on timeout.
* Pure — no IO.
*/
export function isRetriable(step: { maxRetries?: number }): boolean {
return (step.maxRetries ?? 0) > 0;
}
/**
* True when the step has retries remaining.
* Pure — no IO.
*/
export function shouldRetry(maxRetries: number | undefined | null, retryCount: number): boolean {
return retryCount < (maxRetries ?? 0);
}
export type ResumeAction = export type ResumeAction =
| 'keep' | 'keep'
| 're-dispatch' | 're-dispatch'
| 'mark-done' | 'mark-done'
| 'mark-failed' | 'mark-failed'
| 'mark-cancelled'; | 'mark-cancelled'
| 'retry';
/** /**
* Decide what to do with ONE flow step during startup resume (D-9). Pure. * Decide what to do with ONE flow step during startup resume (D-9). Pure.
@@ -131,12 +232,20 @@ export type ResumeAction =
* @param status - flow_steps.status * @param status - flow_steps.status
* @param taskId - flow_steps.task_id (null for code steps or unstarted agent steps) * @param taskId - flow_steps.task_id (null for code steps or unstarted agent steps)
* @param taskState - tasks.state for taskId, or null if the task row is absent * @param taskState - tasks.state for taskId, or null if the task row is absent
* @param retryCount - flow_steps.retry_count (default 0)
* @param maxRetries - flow_steps.max_retries (null = no retry)
*/ */
export function reconcileResumeStep( export function reconcileResumeStep(
status: string, status: string,
taskId: string | null, taskId: string | null,
taskState: string | null, taskState: string | null,
retryCount?: number,
maxRetries?: number | null,
): ResumeAction { ): ResumeAction {
if (status === 'timed_out') {
if (shouldRetry(maxRetries, retryCount ?? 0)) return 'retry';
return 'mark-failed';
}
if (status !== 'running') return 'keep'; if (status !== 'running') return 'keep';
// Running step: decide by its task's current state. // Running step: decide by its task's current state.
if (!taskId || taskState === null) return 're-dispatch'; // task gone or never created if (!taskId || taskState === null) return 're-dispatch'; // task gone or never created
@@ -167,6 +276,60 @@ export function shouldFailOnMissingAgent(agent: string, modeId: string | null):
return agent === 'qwen' && modeId === 'plan'; return agent === 'qwen' && modeId === 'plan';
} }
/**
* Evaluate a SWITCH step: iterate cases in declaration order and return the
* label of the first matching case plus every step id that belongs to a
* non-selected branch. When no case matches, the defaultBranch (if present)
* is the effective choice. If there is no default, all branch steps are
* excluded and the switch returns `chosenCase: null`.
*
* Pure — no IO. The caller adds the returned `excluded` ids to the scheduler
* state's switchResults so downstream decision functions see them as excluded.
*/
export function resolveSwitch(
step: Step,
ctx: StepContext,
): { chosenCase: string | null; excluded: string[] } {
const cases = step.cases;
if (!cases || cases.length === 0) {
// Degenerate switch — nothing to evaluate.
return { chosenCase: null, excluded: [] };
}
// Evaluate conditions in order.
for (const c of cases) {
if (c.condition(ctx)) {
// This case matches — exclude all OTHER branches.
const excluded: string[] = [];
for (const other of cases) {
if (other.label !== c.label) {
excluded.push(...other.stepIds);
}
}
// The default branch is also excluded when a case matched.
if (step.defaultBranch) excluded.push(...step.defaultBranch);
return { chosenCase: c.label, excluded };
}
}
// No case matched — use default branch if present.
if (step.defaultBranch) {
// Default is the chosen branch: exclude all explicit case branches.
const excluded: string[] = [];
for (const c of cases) {
excluded.push(...c.stepIds);
}
return { chosenCase: null, excluded };
}
// No case matched and no default — exclude everything.
const excluded: string[] = [];
for (const c of cases) {
excluded.push(...c.stepIds);
}
return { chosenCase: null, excluded };
}
/** /**
* Evaluate a trigger rule against dependency results. * Evaluate a trigger rule against dependency results.
* - all_success: every dep must be done (not skipped/failed) * - all_success: every dep must be done (not skipped/failed)
@@ -198,7 +361,7 @@ export function evaluateTriggerRule(
* decision per step. Pure — no IO. * decision per step. Pure — no IO.
*/ */
export function reconcileRun( export function reconcileRun(
steps: ReadonlyArray<{ stepId: string; taskId: string | null; status: string }>, steps: ReadonlyArray<{ stepId: string; taskId: string | null; status: string; retryCount?: number; maxRetries?: number | null }>,
taskStates: ReadonlyMap<string, string>, taskStates: ReadonlyMap<string, string>,
): StepResumeDecision[] { ): StepResumeDecision[] {
return steps.map((step) => ({ return steps.map((step) => ({
@@ -207,6 +370,8 @@ export function reconcileRun(
step.status, step.status,
step.taskId, step.taskId,
step.taskId ? (taskStates.get(step.taskId) ?? null) : null, step.taskId ? (taskStates.get(step.taskId) ?? null) : null,
step.retryCount,
step.maxRetries,
), ),
})); }));
} }

View File

@@ -40,11 +40,14 @@ import { getFlow } from '../conductor/flows/index.js';
import { loadPersona } from '../conductor/persona-loader.js'; import { loadPersona } from '../conductor/persona-loader.js';
import type { Band, DispatchFn, Flow, FlowInput, Step, StepContext } from '../conductor/types.js'; import type { Band, DispatchFn, Flow, FlowInput, Step, StepContext } from '../conductor/types.js';
import { import {
buildBatchState,
getReadyInBatch,
isRunComplete, isRunComplete,
manifestSteps, manifestSteps,
partitionReady, partitionReady,
readySteps, readySteps,
reconcileRun, reconcileRun,
resolveSwitch,
type SchedulerState, type SchedulerState,
type StepResumeDecision, type StepResumeDecision,
} from './flow-runner-decisions.js'; } from './flow-runner-decisions.js';
@@ -95,11 +98,14 @@ interface Deps {
interface FlowStepRow { interface FlowStepRow {
step_id: string; step_id: string;
kind: 'agent' | 'code'; kind: 'agent' | 'code' | 'switch';
agent: string | null; agent: string | null;
status: string; status: string;
chat_id: string | null; chat_id: string | null;
output: string | null; output: string | null;
updated_at: string | null;
retry_count: number | null;
max_retries: number | null;
} }
export function createFlowRunner(deps: Deps): FlowRunner { export function createFlowRunner(deps: Deps): FlowRunner {
@@ -263,7 +269,8 @@ export function createFlowRunner(deps: Deps): FlowRunner {
const dispatch: DispatchFn = (agent, task) => dispatchSubAgent(run.project_id, model, agent, task); const dispatch: DispatchFn = (agent, task) => dispatchSubAgent(run.project_id, model, agent, task);
const rows = await sql<FlowStepRow[]>` const rows = await sql<FlowStepRow[]>`
SELECT step_id, kind, agent, status, chat_id, output FROM flow_steps WHERE run_id = ${runId} SELECT step_id, kind, agent, status, chat_id, output, updated_at, retry_count, max_retries
FROM flow_steps WHERE run_id = ${runId}
`; `;
// Re-derive the excluded set (band/when pre-skips) from the flow def + input — // Re-derive the excluded set (band/when pre-skips) from the flow def + input —
@@ -275,6 +282,9 @@ export function createFlowRunner(deps: Deps): FlowRunner {
const done = new Set<string>(); const done = new Set<string>();
const skipped = new Set<string>(); const skipped = new Set<string>();
const inFlight = new Set<string>(); const inFlight = new Set<string>();
const timedOut = new Set<string>();
/** Per-switch routing results — maps switch step id → resolved branch details */
const switchExcluded = new Map<string, { chosenCase: string | null; excluded: Set<string> }>();
const results: Record<string, string> = {}; const results: Record<string, string> = {};
for (const r of rows) { for (const r of rows) {
switch (r.status) { switch (r.status) {
@@ -288,6 +298,9 @@ export function createFlowRunner(deps: Deps): FlowRunner {
case 'running': case 'running':
inFlight.add(r.step_id); inFlight.add(r.step_id);
break; break;
case 'timed_out':
timedOut.add(r.step_id);
break;
case 'failed': case 'failed':
// A failed worker makes the deterministic report untrustworthy — fail the // A failed worker makes the deterministic report untrustworthy — fail the
// whole run (matches the Phase-1 CLI, which throws on a dispatch failure). // whole run (matches the Phase-1 CLI, which throws on a dispatch failure).
@@ -300,17 +313,79 @@ export function createFlowRunner(deps: Deps): FlowRunner {
} }
} }
// ─── Timeout detection ───────────────────────────────────────────────────────
// Check running steps. If a step has been 'running' longer than
// FLOW_STEP_TIMEOUT_MS, mark it timed_out or re-dispatch if retriable.
// Build a context here so the timeout retry path can re-dispatch the step.
const timeoutCtx = buildCtx(input, results, model, dispatch);
const timeoutMs = config.FLOW_STEP_TIMEOUT_MS;
const nowDate = new Date();
let detectedTimedOut = false;
for (const r of rows) {
if (r.status !== 'running') continue;
if (!r.updated_at) continue;
const elapsed = nowDate.getTime() - new Date(r.updated_at).getTime();
if (elapsed <= timeoutMs) continue;
// Step has exceeded the timeout
detectedTimedOut = true;
const retryCount = r.retry_count ?? 0;
const maxRetries = r.max_retries ?? 0;
if (maxRetries > 0 && retryCount < maxRetries) {
// Retriable: re-dispatch the step with an incremented retry_count
const step = flow.steps.find((s) => s.id === r.step_id);
if (!step || step.kind !== 'agent') {
// Non-agent steps can't be retried via dispatch
inFlight.delete(r.step_id);
await failRun(runId, flow, input, model,
`step '${r.step_id}' timed out (non-retriable kind)`, r.step_id);
return;
}
inFlight.delete(r.step_id);
await sql`
UPDATE flow_steps
SET retry_count = ${retryCount + 1}, updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${r.step_id} AND status = 'running'
`;
await dispatchAgentStep(runId, run.project_id, model, step, timeoutCtx);
inFlight.add(r.step_id);
log.warn({ runId, stepId: r.step_id, retry: retryCount + 1, maxRetries },
'flow-runner: step timed out, retrying');
} else {
// Not retriable — mark as timed_out, fail the run
inFlight.delete(r.step_id);
await sql`
UPDATE flow_steps SET status = 'timed_out', updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${r.step_id} AND status = 'running'
`;
timedOut.add(r.step_id);
publishStep(runId, r.step_id, 'timed_out');
await failRun(runId, flow, input, model,
`step '${r.step_id}' timed out`, r.step_id);
return;
}
}
// If we modified any steps, re-query so the state sets reflect the latest DB.
if (detectedTimedOut) {
// Continue with the in-memory state we already adjusted above (inFlight/timedOut
// were mutated directly). No re-query needed.
}
// Drain ready skips + code steps (synchronous), re-evaluating after each batch, // Drain ready skips + code steps (synchronous), re-evaluating after each batch,
// then dispatch the full ready agent wave and wait for their terminal callbacks. // then dispatch the full ready agent wave and wait for their terminal callbacks.
for (;;) { for (;;) {
const state: SchedulerState = { done, skipped, inFlight, excluded }; // Build per-batch state from the current inFlight set for batch parallelism gating.
const batchState = buildBatchState(flow, inFlight);
const state: SchedulerState = { done, skipped, inFlight, excluded, timedOut, batchState, switchResults: switchExcluded };
if (isRunComplete(flow, state)) { if (isRunComplete(flow, state)) {
await finishRun(runId, flow, input, results, model, dispatch); await finishRun(runId, flow, input, results, model, dispatch);
return; return;
} }
const ready = readySteps(flow, state); const ready = getReadyInBatch(readySteps(flow, state), state, flow);
if (ready.length === 0) { if (ready.length === 0) {
if (inFlight.size > 0) return; // agents in flight will re-enter via the hook if (inFlight.size > 0) return; // agents in flight will re-enter via the hook
await failRun(runId, flow, input, model, 'unsatisfiable dependencies / cycle'); await failRun(runId, flow, input, model, 'unsatisfiable dependencies / cycle');
@@ -329,6 +404,31 @@ export function createFlowRunner(deps: Deps): FlowRunner {
continue; // re-evaluate — a skip can settle a fan-in step's deps continue; // re-evaluate — a skip can settle a fan-in step's deps
} }
// SWITCH steps run synchronously — evaluate conditions, update the excluded
// set in SchedulerState, and mark themselves complete. Non-selected branch
// step ids are excluded from ever running.
const switchReady = toRun.filter((s) => s.kind === 'switch');
if (switchReady.length > 0) {
for (const s of switchReady) {
let result: { chosenCase: string | null; excluded: string[] };
try {
result = resolveSwitch(s, buildCtx(input, results, model, dispatch));
} catch (err) {
await failRun(runId, flow, input, model, `switch step '${s.id}' threw: ${errMsg(err)}`, s.id);
return;
}
switchExcluded.set(s.id, {
chosenCase: result.chosenCase,
excluded: new Set(result.excluded),
});
const outputText = result.chosenCase ? `branch:${result.chosenCase}` : '';
await markStep(runId, s.id, 'completed', outputText);
results[s.id] = outputText;
done.add(s.id);
}
continue; // re-evaluate — excluded steps may unblock dependents
}
const codeReady = toRun.filter((s) => s.kind === 'code'); const codeReady = toRun.filter((s) => s.kind === 'code');
if (codeReady.length > 0) { if (codeReady.length > 0) {
for (const s of codeReady) { for (const s of codeReady) {
@@ -545,7 +645,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
function publishStep( function publishStep(
runId: string, runId: string,
stepId: string, stepId: string,
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'blocked', status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'blocked' | 'timed_out',
extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string }, extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string },
): void { ): void {
publishUser({ publishUser({
@@ -683,6 +783,38 @@ export function createFlowRunner(deps: Deps): FlowRunner {
log.info({ runId, stepId: step.step_id, taskId: task!.id }, 'flow-runner: step re-dispatched on resume'); log.info({ runId, stepId: step.step_id, taskId: task!.id }, 'flow-runner: step re-dispatched on resume');
break; break;
} }
case 'retry': {
// Like re-dispatch but increments retry_count and sets status to 'running'.
if (!step.input) {
await sql`
UPDATE flow_steps
SET status = 'failed', error = 'retry: no stored prompt',
updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${step.step_id}
`;
break;
}
const chatIdR = step.chat_id;
const [chatR] = chatIdR
? await sql<{ session_id: string }[]>`SELECT session_id FROM chats WHERE id = ${chatIdR}`
: [];
const sessionIdR = chatR?.session_id ?? null;
const [taskR] = await sql<{ id: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, session_id, chat_id)
VALUES (${projectId}, ${step.input}, 'qwen', ${model}, 'plan', ${sessionIdR}, ${chatIdR})
RETURNING id
`;
await sql`
UPDATE flow_steps
SET task_id = ${taskR!.id}, retry_count = retry_count + 1, status = 'running',
updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${step.step_id}
`;
log.info({ runId, stepId: step.step_id, taskId: taskR!.id },
'flow-runner: step retried on resume');
break;
}
} }
} }
@@ -697,7 +829,9 @@ export function createFlowRunner(deps: Deps): FlowRunner {
status: string; status: string;
chat_id: string | null; chat_id: string | null;
input: string | null; input: string | null;
}[]>`SELECT step_id, task_id, status, chat_id, input FROM flow_steps WHERE run_id = ${run.id}`; retry_count: number | null;
max_retries: number | null;
}[]>`SELECT step_id, task_id, status, chat_id, input, retry_count, max_retries FROM flow_steps WHERE run_id = ${run.id}`;
// Load task states for all referenced tasks in one query. // Load task states for all referenced tasks in one query.
const taskIds = rows.map((r) => r.task_id).filter((id): id is string => id !== null); const taskIds = rows.map((r) => r.task_id).filter((id): id is string => id !== null);
@@ -710,7 +844,13 @@ export function createFlowRunner(deps: Deps): FlowRunner {
} }
const decisions = reconcileRun( const decisions = reconcileRun(
rows.map((r) => ({ stepId: r.step_id, taskId: r.task_id, status: r.status })), rows.map((r) => ({
stepId: r.step_id,
taskId: r.task_id,
status: r.status,
retryCount: r.retry_count ?? undefined,
maxRetries: r.max_retries,
})),
taskStates, taskStates,
); );
@@ -752,13 +892,13 @@ export function createFlowRunner(deps: Deps): FlowRunner {
// Mark all non-terminal steps cancelled and collect in-flight task_ids. // Mark all non-terminal steps cancelled and collect in-flight task_ids.
const steps = await sql<{ step_id: string; task_id: string | null; kind: string }[]>` const steps = await sql<{ step_id: string; task_id: string | null; kind: string }[]>`
SELECT step_id, task_id, kind FROM flow_steps SELECT step_id, task_id, kind FROM flow_steps
WHERE run_id = ${runId} AND status NOT IN ('completed', 'failed', 'cancelled', 'skipped') WHERE run_id = ${runId} AND status NOT IN ('completed', 'failed', 'cancelled', 'skipped', 'timed_out')
`; `;
if (steps.length > 0) { if (steps.length > 0) {
await sql` await sql`
UPDATE flow_steps SET status = 'cancelled', updated_at = clock_timestamp() UPDATE flow_steps SET status = 'cancelled', updated_at = clock_timestamp()
WHERE run_id = ${runId} AND status NOT IN ('completed', 'failed', 'cancelled', 'skipped') WHERE run_id = ${runId} AND status NOT IN ('completed', 'failed', 'cancelled', 'skipped', 'timed_out')
`; `;
for (const s of steps) { for (const s of steps) {
if (s.kind === 'agent') publishStep(runId, s.step_id, 'cancelled', { run_status: 'cancelled' }); if (s.kind === 'agent') publishStep(runId, s.step_id, 'cancelled', { run_status: 'cancelled' });

View File

@@ -0,0 +1,341 @@
/**
* v2.10 — PaseoClient: thin CLI-based client for the Paseo daemon.
*
* Paseo is a multi-agent hub daemon running at a configurable address
* (default Unix socket / localhost:6767). This client wraps the `paseo` CLI
* via child_process spawn for all operations (the daemon does not expose a
* separate REST API for write operations). Read operations (listAgents,
* getAgentStatus) use `paseo ls --json` / `paseo inspect --json`; write
* operations (import, archive, send) use the corresponding subcommands.
*
* Spec: openspec/changes/v2-10-paseo-integration/design.md.
*/
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { createInterface } from 'node:readline';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Listing entry from `paseo ls --json`. Fields are lowercase. */
export interface PaseoAgentListItem {
id: string;
shortId: string;
name: string;
provider: string;
status: string;
cwd?: string;
created?: string;
thinking?: string;
}
/** Detailed agent info from `paseo inspect --json`. Fields are PascalCase. */
export interface PaseoAgentDetail {
Id: string;
Name: string;
Provider: string;
Model?: string;
Status: string;
Thinking?: string;
Archived: boolean;
ArchivedAt?: string | null;
Cwd?: string;
CreatedAt: string;
UpdatedAt: string;
Mode?: string;
AvailableModes?: Array<{ id: string; label: string }>;
Capabilities?: {
Streaming?: boolean;
Persistence?: boolean;
DynamicModes?: boolean;
McpServers?: boolean;
};
Labels?: Record<string, string>;
Worktree?: string | null;
ParentAgentId?: string | null;
}
/** Result of `paseo send --json`. */
export interface PaseoSendResult {
/** The agent's textual response. */
text?: string;
/** Structured output if the agent produced any. */
output?: unknown;
/** Error message if the turn failed. */
error?: string;
/** True if the turn completed successfully. */
ok?: boolean;
}
export interface PaseoClientConfig {
/** Path to the paseo binary. Default: auto-resolved from PATH. */
paseoBin: string;
/**
* Explicit `--host <host>` value for CLI calls.
* Format: `host:port` or `tcp://host:port?ssl=true&password=secret`.
* Omit to use the CLI default (Unix socket, fallback localhost:6767).
*/
cliHost?: string;
}
const DEFAULT_PASEO_BIN = 'paseo';
// ─── Client ──────────────────────────────────────────────────────────────────
export class PaseoClientError extends Error {
constructor(
message: string,
public readonly command: string,
public readonly exitCode: number | null,
public readonly stderr: string,
) {
super(message);
this.name = 'PaseoClientError';
}
}
export class PaseoClient {
/** @internal visible for testing */
readonly bin: string;
private readonly hostArgs: string[];
constructor(config?: Partial<PaseoClientConfig>) {
this.bin = config?.paseoBin ?? DEFAULT_PASEO_BIN;
this.hostArgs = config?.cliHost ? ['--host', config.cliHost] : [];
}
// ─── Read operations (CLI `ls --json`, `inspect --json`) ──────────────────
/** List all non-archived agents. */
async listAgents(): Promise<PaseoAgentListItem[]> {
const raw = await this.runJson(['ls', '--json', ...this.hostArgs]);
return raw as PaseoAgentListItem[];
}
/** Get detailed status for a single agent by ID or prefix. */
async getAgentStatus(agentId: string): Promise<PaseoAgentDetail> {
const raw = await this.runJson(['inspect', '--json', agentId, ...this.hostArgs]);
return raw as PaseoAgentDetail;
}
/**
* Quick liveness check — runs `paseo ls --json --limit 1` and returns success.
* The daemon is healthy if the CLI exits 0.
*/
async health(): Promise<{ status: string }> {
try {
await this.runCli(['ls', '--json', '--limit', '1', ...this.hostArgs]);
return { status: 'ok' };
} catch {
return { status: 'error' };
}
}
// ─── Write operations (CLI subcommands) ───────────────────────────────────
/**
* Import a provider session as a Paseo agent.
* Uses `paseo import <sessionId> --provider <provider> [--label k=v]`.
*/
async importAgent(
sessionId: string,
provider: string,
labels?: Record<string, string>,
): Promise<PaseoAgentDetail> {
const args: string[] = ['import', '--json', ...this.hostArgs];
if (provider) {
args.push('--provider', provider);
}
if (labels) {
for (const [k, v] of Object.entries(labels)) {
args.push('--label', `${k}=${v}`);
}
}
args.push(sessionId);
const raw = await this.runJson(args);
return raw as PaseoAgentDetail;
}
/** Archive (soft-delete) a Paseo agent by ID or prefix. */
async archiveAgent(agentId: string): Promise<void> {
await this.runCli(['archive', '--json', ...this.hostArgs, agentId]);
}
/**
* Send a prompt to an existing agent.
*
* By default waits for the agent to complete the turn (streams text events
* via the optional `onEvent` callback) and returns the structured result.
* Pass `noWait: true` to fire-and-forget.
*/
async sendPrompt(
agentId: string,
prompt: string,
options?: {
noWait?: boolean;
onEvent?: (event: { type: 'text' | 'reasoning'; text: string }) => void;
signal?: AbortSignal;
},
): Promise<PaseoSendResult> {
const args: string[] = ['send', '--json', ...this.hostArgs];
if (options?.noWait) {
args.push('--no-wait');
}
args.push(agentId, prompt);
// With --json and no --no-wait, the output is JSON after completion.
// For streaming, we read stderr without --json for real-time text.
const raw = await this.runCli(args, options?.signal);
try {
return JSON.parse(raw) as PaseoSendResult;
} catch {
return { text: raw, ok: true };
}
}
/**
* Stream-send: runs `paseo send` WITHOUT `--json`, forward text/reasoning
* lines to onEvent in real time. Use when the caller wants to stream agent
* output as it arrives rather than wait for the full JSON result.
*/
async streamSend(
agentId: string,
prompt: string,
onEvent: (event: { type: 'text' | 'reasoning'; text: string }) => void,
signal?: AbortSignal,
): Promise<PaseoSendResult> {
return new Promise<PaseoSendResult>((resolve, reject) => {
const args = ['send', ...this.hostArgs, agentId, prompt];
const child = spawn(this.bin, args, {
stdio: ['ignore', 'pipe', 'pipe'],
signal,
});
let stdout = '';
let stderr = '';
if (child.stdout) {
const rl = createInterface({ input: child.stdout });
rl.on('line', (line: string) => {
stdout += line + '\n';
// Forward as text event for real-time display
onEvent({ type: 'text', text: line + '\n' });
});
}
if (child.stderr) {
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
}
once(child, 'close').then((raw) => {
const exitCode = (raw[0] as number | null) ?? 0;
if (exitCode !== 0) {
reject(
new PaseoClientError(
`paseo send failed (exit ${exitCode}): ${stderr.trim()}`,
'send',
exitCode,
stderr,
),
);
return;
}
resolve({ text: stdout, ok: true });
});
child.on('error', reject);
});
}
/** Interrupt/stop a running agent. */
async stopAgent(agentId: string): Promise<void> {
await this.runCli(['stop', ...this.hostArgs, agentId]);
}
// ─── Private helpers ───────────────────────────────────────────────────────
/**
* Run a CLI command and return stdout as a string.
* Throws PaseoClientError on non-zero exit.
*/
private async runCli(
args: string[],
signal?: AbortSignal,
): Promise<string> {
return new Promise<string>((resolve, reject) => {
const child = spawn(this.bin, args, {
stdio: ['ignore', 'pipe', 'pipe'],
signal,
});
let stdout = '';
let stderr = '';
if (child.stdout) {
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
}
if (child.stderr) {
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
}
child.on('error', (err: Error) => {
// If signal aborted, treat as cancellation not error
if (signal?.aborted) {
resolve('');
return;
}
reject(err);
});
once(child, 'close').then((raw) => {
const exitCode = (raw[0] as number | null) ?? 0;
if (signal?.aborted) {
resolve('');
return;
}
if (exitCode !== 0) {
const msg = stderr.trim() || `exit code ${exitCode}`;
reject(
new PaseoClientError(
`paseo ${args[0] ?? '?'} failed: ${msg}`,
args[0] ?? '?',
exitCode,
stderr,
),
);
return;
}
resolve(stdout);
});
});
}
/**
* Run a CLI command and parse stdout as JSON.
* Throws PaseoClientError on non-zero exit or parse failure.
*/
private async runJson(args: string[]): Promise<unknown> {
const stdout = await this.runCli(args);
try {
return JSON.parse(stdout);
} catch (err) {
throw new PaseoClientError(
`paseo ${args[0] ?? '?'} returned invalid JSON: ${(stdout || '<empty>').slice(0, 200)}`,
args[0] ?? '?',
0,
stdout,
);
}
}
}

View File

@@ -18,8 +18,10 @@ import { registerCoderProxy } from './routes/coder-proxy.js';
import { registerModelRoutes } from './routes/models.js'; import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js'; import { registerAgentRoutes } from './routes/agents.js';
import { registerSkillsRoutes } from './routes/skills.js'; import { registerSkillsRoutes } from './routes/skills.js';
import { registerTraceRoutes } from './routes/traces.js';
import { registerToolsRoutes } from './routes/tools.js'; import { registerToolsRoutes } from './routes/tools.js';
import { registerAnalyticsRoutes } from './routes/analytics.js'; import { registerAnalyticsRoutes } from './routes/analytics.js';
import { registerMemoryRoutes } from './routes/memory.js';
import { registerInferenceSettingsRoutes } from './routes/inference-settings.js'; import { registerInferenceSettingsRoutes } from './routes/inference-settings.js';
import { createInferenceRunner } from './services/inference/index.js'; import { createInferenceRunner } from './services/inference/index.js';
import { createBroker } from './services/broker.js'; import { createBroker } from './services/broker.js';
@@ -124,8 +126,10 @@ async function main() {
registerAgentRoutes(app, sql); registerAgentRoutes(app, sql);
registerSidebarRoutes(app, sql); registerSidebarRoutes(app, sql);
registerChatRoutes(app, sql, broker); registerChatRoutes(app, sql, broker);
registerTraceRoutes(app, sql);
registerToolsRoutes(app, sql); registerToolsRoutes(app, sql);
registerAnalyticsRoutes(app, sql); registerAnalyticsRoutes(app, sql);
registerMemoryRoutes(app, sql);
registerInferenceSettingsRoutes(app); registerInferenceSettingsRoutes(app);
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or // Batch 9.6: warm the skills cache at boot and surface the count. Empty or

View File

@@ -0,0 +1,38 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import type { ToolTrace } from '../services/tool-traces.js';
export function registerTraceRoutes(app: FastifyInstance, sql: Sql): void {
app.get<{ Params: { id: string }; Querystring: { limit?: string; offset?: string } }>(
'/api/chats/:id/traces',
async (req, reply) => {
const chat = await sql`SELECT id FROM chats WHERE id = ${req.params.id}`;
if (chat.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200);
const offset = Math.max(Number(req.query.offset) || 0, 0);
const rows = await sql<ToolTrace[]>`
SELECT * FROM tool_traces
WHERE chat_id = ${req.params.id}
ORDER BY started_at ASC
LIMIT ${limit}
OFFSET ${offset}
`;
const [countRow] = await sql<{ count: number }[]>`
SELECT count(*)::int AS count FROM tool_traces WHERE chat_id = ${req.params.id}
`;
return {
data: rows,
total: countRow?.count ?? 0,
limit,
offset,
};
},
);
}

View File

@@ -3,6 +3,7 @@ import type { Sql } from '../db.js';
import type { Broker } from '../services/broker.js'; import type { Broker } from '../services/broker.js';
import type { Message } from '../types/api.js'; import type { Message } from '../types/api.js';
import { MESSAGE_COLUMNS } from '../services/message-columns.js'; import { MESSAGE_COLUMNS } from '../services/message-columns.js';
import { loadAgentSnapshot } from '../services/session-snapshots.js';
export function registerWebSocket( export function registerWebSocket(
app: FastifyInstance, app: FastifyInstance,
@@ -33,6 +34,24 @@ export function registerWebSocket(
`; `;
socket.send(JSON.stringify({ type: 'snapshot', messages })); socket.send(JSON.stringify({ type: 'snapshot', messages }));
// v2.7.x: on reconnect, restore agent snapshot state so the frontend
// knows there's an ongoing agent turn. Best-effort per chat; most
// sessions won't have any snapshots.
const chats = await sql<{ id: string }[]>`SELECT id FROM chats WHERE session_id = ${sessionId}`;
for (const chat of chats) {
const agentSnapshot = await loadAgentSnapshot(sql, chat.id).catch(() => null);
if (agentSnapshot) {
socket.send(JSON.stringify({
type: 'agent_snapshot',
chat_id: chat.id,
agent: agentSnapshot.agent,
model: agentSnapshot.model,
mode: agentSnapshot.mode,
turn_number: agentSnapshot.turn_number,
}));
}
}
const unsubscribe = broker.subscribe(sessionId, (frame) => { const unsubscribe = broker.subscribe(sessionId, (frame) => {
if (socket.readyState !== socket.OPEN) return; if (socket.readyState !== socket.OPEN) return;
try { try {

View File

@@ -414,3 +414,55 @@ END $$;
-- Remove the v2.0.5 arena_id column (replaced by the new Arena feature). -- Remove the v2.0.5 arena_id column (replaced by the new Arena feature).
ALTER TABLE tasks DROP COLUMN IF EXISTS arena_id; ALTER TABLE tasks DROP COLUMN IF EXISTS arena_id;
-- v2.x-tool-traces: per-call tool execution records for observability.
CREATE TABLE IF NOT EXISTS tool_traces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
turn_number INTEGER NOT NULL,
tool_name TEXT NOT NULL,
tool_input JSONB NOT NULL,
tool_output TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
finished_at TIMESTAMPTZ,
latency_ms INTEGER,
tokens_used INTEGER,
cache_tokens INTEGER,
reasoning_tokens INTEGER,
error TEXT,
outcome TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_tool_traces_chat ON tool_traces(chat_id, created_at);
-- v2.x-tool-traces: active tool call state for in-flight instrumentation.
CREATE TABLE IF NOT EXISTS tool_trace_states (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
turn_number INTEGER NOT NULL,
tool_name TEXT NOT NULL,
tool_input JSONB NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
-- agent_snapshots: persistent agent session state for cross-refresh resume.
CREATE TABLE IF NOT EXISTS agent_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
model TEXT NOT NULL,
agent TEXT,
mode TEXT,
turn_number INTEGER NOT NULL DEFAULT 0,
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
tool_states JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_agent_snapshots_chat ON agent_snapshots(chat_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_snapshots_chat_unique ON agent_snapshots(chat_id);

View File

@@ -26,6 +26,7 @@
import { access, copyFile, realpath } from 'node:fs/promises'; import { access, copyFile, realpath } from 'node:fs/promises';
import { isAbsolute, join, resolve, sep } from 'node:path'; import { isAbsolute, join, resolve, sep } from 'node:path';
import { truncateIfNeeded } from './truncate.js'; import { truncateIfNeeded } from './truncate.js';
import { callBoocontext } from './boocontext_client.js';
// v1.13.12 fix: codecontext crashes on empty source files (upstream issue #37) // v1.13.12 fix: codecontext crashes on empty source files (upstream issue #37)
// when it can't ignore them. The .codecontextignore.template ships with the // when it can't ignore them. The .codecontextignore.template ships with the
@@ -119,11 +120,16 @@ export async function callCodecontext(
req: CodecontextRequest, req: CodecontextRequest,
fetcher: typeof fetch = fetch, fetcher: typeof fetch = fetch,
): Promise<CodecontextResponse> { ): Promise<CodecontextResponse> {
// DEPRECATED: This function routes through the Go codecontext sidecar at // Phase 4: try boocontext MCP first. Falls back to the HTTP sidecar if the
// http://codecontext:8080. New callers should use boocontext MCP instead. // MCP server is not available or the tool doesn't exist there.
try {
return await callBoocontext({ toolName: req.toolName, args: req.args });
} catch (err) {
console.warn( console.warn(
`[deprecated] callCodecontext("${req.toolName}") — route through boocontext MCP instead`, `[codecontext_client] boocontext MCP unavailable for "${req.toolName}", falling back to HTTP sidecar: ${err instanceof Error ? err.message : String(err)}`,
); );
}
// Step 1: realpath the project root, then realpath the requested target_dir // Step 1: realpath the project root, then realpath the requested target_dir
// (defaulting to projectPath when the caller didn't pass one — the 12 wrappers // (defaulting to projectPath when the caller didn't pass one — the 12 wrappers
// never pass target_dir; tests can override). A non-existent target_dir // never pass target_dir; tests can override). A non-existent target_dir

View File

@@ -20,6 +20,7 @@ import { resolveGrantRoot } from '../grant_resolver.js';
import { stripToolMarkup } from './tool-call-parser.js'; import { stripToolMarkup } from './tool-call-parser.js';
import { repairToolInput } from './tool-input-repair.js'; import { repairToolInput } from './tool-input-repair.js';
import type { FailureKind } from './mistake-tracker.js'; import type { FailureKind } from './mistake-tracker.js';
import { insertToolTrace, updateToolTrace } from '../tool-traces.js';
import type { import type {
InferenceContext, InferenceContext,
StreamResult, StreamResult,
@@ -175,6 +176,7 @@ export async function executeToolPhase(
session: Session, session: Session,
projectRoot: string, projectRoot: string,
agent?: Agent | null, agent?: Agent | null,
turnNumber?: number,
): Promise<ToolPhaseResult> { ): Promise<ToolPhaseResult> {
const { sessionId, chatId, assistantMessageId } = args; const { sessionId, chatId, assistantMessageId } = args;
const content = stripToolMarkup(result.content, { final: true }); const content = stripToolMarkup(result.content, { final: true });
@@ -378,11 +380,53 @@ export async function executeToolPhase(
}); });
return; return;
} }
// tool_trace instrumentation - start
const traceId = crypto.randomUUID();
const traceStartTime = Date.now();
const startedAtIso = new Date().toISOString();
insertToolTrace(ctx.sql, {
session_id: sessionId,
chat_id: chatId,
message_id: assistantMessageId,
turn_number: turnNumber ?? 0,
tool_name: tc.name,
tool_input: tc.args as Record<string, unknown>,
}).catch(() => {});
ctx.publish(sessionId, {
type: 'tool_trace_start',
trace_id: traceId,
message_id: assistantMessageId,
chat_id: chatId,
tool_name: tc.name,
tool_input: tc.args as Record<string, unknown>,
started_at: startedAtIso,
});
const tres = await executeToolCall( const tres = await executeToolCall(
projectRoot, tc, session.allowed_read_paths, projectRoot, tc, session.allowed_read_paths,
{ sql: ctx.sql, sessionId }, { sql: ctx.sql, sessionId },
ctx.hooks, sessionId, ctx.hooks, sessionId,
); );
// tool_trace instrumentation - finish
const finishedAtIso = new Date().toISOString();
const latencyMs = Date.now() - traceStartTime;
updateToolTrace(ctx.sql, traceId, {
finished_at: finishedAtIso,
...(tres.outcome === 'success' && tres.output != null ? { tool_output: JSON.stringify(tres.output) } : {}),
latency_ms: latencyMs,
outcome: tres.outcome,
...(tres.error ? { error: tres.error } : {}),
}).catch(() => {});
ctx.publish(sessionId, {
type: 'tool_trace_finish',
trace_id: traceId,
message_id: assistantMessageId,
chat_id: chatId,
tool_name: tc.name,
finished_at: finishedAtIso,
outcome: tres.outcome,
latency_ms: latencyMs,
...(tres.error ? { error: tres.error } : {}),
});
// vWhale: PostToolUse hook (best-effort, non-blocking). // vWhale: PostToolUse hook (best-effort, non-blocking).
if (ctx.hooks) { if (ctx.hooks) {
ctx.hooks.run('PostToolUse', { ctx.hooks.run('PostToolUse', {

View File

@@ -37,6 +37,12 @@ import type {
StreamResult, StreamResult,
TurnArgs, TurnArgs,
} from './types.js'; } from './types.js';
import { saveAgentSnapshot } from '../session-snapshots.js';
// vWhale: auto-fix loop — after write tools, build the project and inject
// errors. Uses execFile (no shell) against the project root.
import { execFile } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { import {
runCapHitSummary, runCapHitSummary,
runDoomLoopSummary, runDoomLoopSummary,
@@ -44,6 +50,71 @@ import {
insertMistakeRecoverySentinel, insertMistakeRecoverySentinel,
} from './sentinel-summaries.js'; } from './sentinel-summaries.js';
// vWhale: auto-fix — detect build command from package.json, run it, return
// error text for injection into next iteration. Best-effort, never throws.
const BUILD_TIMEOUT_MS = 60_000;
const BUILD_OUTPUT_CAP = 8_000;
async function detectAndRunBuild(
ctx: InferenceContext,
projectRoot: string,
sessionId: string,
chatId: string,
model: string,
existingNote: string | undefined,
): Promise<string | undefined> {
// Only run for DeepSeek models (local Qwen models don't benefit from build loop).
if (!model.startsWith('deepseek-')) return undefined;
// Detect build command from package.json in project root.
const pkgPath = join(projectRoot, 'package.json');
if (!existsSync(pkgPath)) return undefined;
let buildCmd: string | null = null;
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> };
if (pkg.scripts?.build) buildCmd = 'build';
else if (pkg.scripts?.compile) buildCmd = 'compile';
else if (pkg.scripts?.typecheck) buildCmd = 'typecheck';
} catch {
return undefined;
}
if (!buildCmd) return undefined;
// Detect package manager.
const hasPnpm = existsSync(join(projectRoot, 'pnpm-lock.yaml'));
const hasYarn = existsSync(join(projectRoot, 'yarn.lock'));
const pm = hasPnpm ? 'pnpm' : hasYarn ? 'yarn' : 'npm';
// Run the build.
try {
const out = await new Promise<string>((resolve, reject) => {
execFile(pm, ['run', buildCmd!], { cwd: projectRoot, timeout: BUILD_TIMEOUT_MS, maxBuffer: BUILD_OUTPUT_CAP * 2 },
(err, stdout, stderr) => {
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
resolve(''); // package manager not found — skip
return;
}
const merged = (stdout + '\n' + stderr).trim();
resolve(merged.slice(0, BUILD_OUTPUT_CAP));
},
);
});
if (!out) return undefined; // build succeeded or no output
ctx.log.info({ sessionId, chatId, buildCmd, outputLen: out.length }, 'auto-fix: build failed');
// Truncate if existing note exists
const combined = existingNote
? existingNote + '\n\n--- Build error ---\n' + out.slice(0, BUILD_OUTPUT_CAP - existingNote.length)
: '--- Build error ---\n' + out.slice(0, BUILD_OUTPUT_CAP);
return combined;
} catch {
return undefined;
}
}
// P5: MAX_STEPS moved to ./turn-config.ts (with resolveTurnConfig). Re-exported // P5: MAX_STEPS moved to ./turn-config.ts (with resolveTurnConfig). Re-exported
// here so the public surface (index.ts → './turn.js') is unchanged. // here so the public surface (index.ts → './turn.js') is unchanged.
export { MAX_STEPS } from './turn-config.js'; export { MAX_STEPS } from './turn-config.js';
@@ -240,7 +311,7 @@ export async function runAssistantTurn(
// ---- tool phase ---- // ---- tool phase ----
let toolPhaseResult: ToolPhaseResult; let toolPhaseResult: ToolPhaseResult;
try { try {
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent); toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent, stepNumber);
} catch (err) { } catch (err) {
// Tool phase errors are unexpected (individual tool failures are // Tool phase errors are unexpected (individual tool failures are
// caught inside executeToolPhase). Log and break. // caught inside executeToolPhase). Log and break.
@@ -260,6 +331,17 @@ export async function runAssistantTurn(
recordStep(mistakeTracker, o); recordStep(mistakeTracker, o);
} }
// vWhale: auto-fix — after write tools, attempt build and inject errors.
const WRITE_TOOLS = new Set(['edit_file', 'create_file', 'delete_file', 'apply_pending']);
const hasWriteTools = toolPhaseResult.toolCalls.some((tc) => WRITE_TOOLS.has(tc.name));
if (hasWriteTools) {
detectAndRunBuild(ctx, projectRoot, sessionId, chatId, iterSession.model, pendingRecoveryNote)
.then((buildError) => {
if (buildError) pendingRecoveryNote = buildError;
})
.catch(() => {});
}
// v#12 MistakeTracker: post-tool decision (pure). 'stop' = the tool phase // v#12 MistakeTracker: post-tool decision (pure). 'stop' = the tool phase
// returned a non-'continue' action ('paused' for user input, or // returned a non-'continue' action ('paused' for user input, or
// 'synthesis_done') — neither a nudge nor an escalate would change the // 'synthesis_done') — neither a nudge nor an escalate would change the
@@ -336,6 +418,19 @@ export async function runAssistantTurn(
}).catch(() => {}); }).catch(() => {});
} }
// ---- persist agent snapshot (best-effort, never blocks inference) ----
const snapLoaded = await loadContext(ctx.sql, sessionId, chatId).catch(() => null);
if (snapLoaded) {
await saveAgentSnapshot(ctx.sql, chatId, {
session_id: sessionId,
model: snapLoaded.session.model,
agent: agent?.name ?? null,
mode: null,
turn_number: stepNumber,
messages: snapLoaded.history.map((m) => ({ role: m.role, content: m.content })),
}).catch(() => {});
}
// ---- post-loop: step-cap sentinel ---- // ---- post-loop: step-cap sentinel ----
// When the loop exits because stepNumber reached effectiveCap, the last // When the loop exits because stepNumber reached effectiveCap, the last
// iteration's tool phase returned 'continue' with a nextAssistantId that // iteration's tool phase returned 'continue' with a nextAssistantId that

View File

@@ -46,6 +46,9 @@ export interface InferenceFrame {
| 'error' | 'error'
| 'flow_run_started' | 'flow_run_started'
| 'flow_run_step_updated' | 'flow_run_step_updated'
// tool trace frames
| 'tool_trace_start'
| 'tool_trace_finish'
// arena frames // arena frames
| 'battle_started' | 'battle_started'
| 'contestant_updated' | 'contestant_updated'
@@ -82,6 +85,15 @@ export interface InferenceFrame {
reasoning_tokens?: number | null; reasoning_tokens?: number | null;
session_id?: string; session_id?: string;
name?: string; name?: string;
// tool trace frames
trace_id?: string;
tool_name?: string;
tool_input?: Record<string, unknown>;
tool_output?: string | null;
latency_ms?: number;
outcome?: string;
// agent snapshot restore
agent?: string | null;
// orchestrator frames ([D-6]) // orchestrator frames ([D-6])
run_id?: string; run_id?: string;
flow_name?: string; flow_name?: string;

View File

@@ -3,4 +3,9 @@ export { formatMemoryBlock } from './prompt.js';
export { scanMemoryScopes } from './scan.js'; export { scanMemoryScopes } from './scan.js';
export { parseMemoryEntries } from './entries.js'; export { parseMemoryEntries } from './entries.js';
export { ensureMemoryScaffold, getMemoryRoot } from './paths.js'; export { ensureMemoryScaffold, getMemoryRoot } from './paths.js';
export { ContextTier } from './context-tier.js';
export { DeepDream } from './deep-dream.js';
export { CoreTier } from './core-tier.js';
export type { MemoryEntry } from './entries.js'; export type { MemoryEntry } from './entries.js';
export type { ContextTierConfig, ConversationTurn } from './context-tier.js';
export type { CoreTierEntry, CoreTierSearchResult, CoreTierSearchOptions } from './core-tier.js';

View File

@@ -0,0 +1,51 @@
import type { Sql } from '../db.js';
export interface AgentSnapshot {
id: string;
session_id: string;
chat_id: string;
model: string;
agent: string | null;
mode: string | null;
turn_number: number;
messages: unknown[];
tool_states: unknown[];
created_at: string;
updated_at: string;
}
/** Save or update the agent snapshot for a chat (UPSERT). */
export async function saveAgentSnapshot(sql: Sql, chatId: string, data: {
session_id: string;
model: string;
agent?: string | null;
mode?: string | null;
turn_number: number;
messages: unknown[];
tool_states?: unknown[];
}): Promise<void> {
await sql`
INSERT INTO agent_snapshots (session_id, chat_id, model, agent, mode, turn_number, messages, tool_states, updated_at)
VALUES (${data.session_id}, ${chatId}, ${data.model}, ${data.agent ?? null}, ${data.mode ?? null}, ${data.turn_number}, ${sql.json(data.messages as never)}, ${sql.json((data.tool_states ?? []) as never)}, clock_timestamp())
ON CONFLICT (chat_id)
DO UPDATE SET
model = EXCLUDED.model,
agent = EXCLUDED.agent,
mode = EXCLUDED.mode,
turn_number = EXCLUDED.turn_number,
messages = EXCLUDED.messages,
tool_states = EXCLUDED.tool_states,
updated_at = clock_timestamp()
`;
}
/** Load the agent snapshot for a chat. Returns null if no snapshot exists. */
export async function loadAgentSnapshot(sql: Sql, chatId: string): Promise<AgentSnapshot | null> {
const rows = await sql<AgentSnapshot[]>`SELECT * FROM agent_snapshots WHERE chat_id = ${chatId}`;
return rows[0] ?? null;
}
/** Delete the agent snapshot for a chat (call when session ends). */
export async function deleteAgentSnapshot(sql: Sql, chatId: string): Promise<void> {
await sql`DELETE FROM agent_snapshots WHERE chat_id = ${chatId}`;
}

View File

@@ -0,0 +1,92 @@
import type { Sql } from '../db.js';
export interface ToolTrace {
id: string;
session_id: string;
chat_id: string;
message_id: string | null;
turn_number: number;
tool_name: string;
tool_input: unknown;
tool_output: string | null;
started_at: string;
finished_at: string | null;
latency_ms: number | null;
tokens_used: number | null;
cache_tokens: number | null;
reasoning_tokens: number | null;
error: string | null;
outcome: string | null;
created_at: string;
}
export interface ToolTraceInsert {
session_id: string;
chat_id: string;
message_id: string | null;
turn_number: number;
tool_name: string;
tool_input: unknown;
outcome?: string;
}
export interface ToolTraceUpdate {
finished_at?: string;
latency_ms?: number;
tool_output?: string;
tokens_used?: number;
cache_tokens?: number;
reasoning_tokens?: number;
error?: string;
outcome?: string;
}
export async function insertToolTrace(
sql: Sql,
insert: ToolTraceInsert,
): Promise<ToolTrace> {
const [row] = await sql<ToolTrace[]>`
INSERT INTO tool_traces (
session_id, chat_id, message_id, turn_number,
tool_name, tool_input, outcome
) VALUES (
${insert.session_id}, ${insert.chat_id}, ${insert.message_id},
${insert.turn_number}, ${insert.tool_name},
${sql.json(insert.tool_input as never)},
${insert.outcome ?? null}
)
RETURNING *
`;
if (!row) throw new Error('insertToolTrace returned no row');
return row;
}
export async function updateToolTrace(
sql: Sql,
id: string,
updates: ToolTraceUpdate,
): Promise<ToolTrace | null> {
const cols: string[] = [];
const vals: any[] = [];
if (updates.finished_at !== undefined) { cols.push('finished_at'); vals.push(updates.finished_at); }
if (updates.latency_ms !== undefined) { cols.push('latency_ms'); vals.push(updates.latency_ms); }
if (updates.tool_output !== undefined) { cols.push('tool_output'); vals.push(updates.tool_output); }
if (updates.tokens_used !== undefined) { cols.push('tokens_used'); vals.push(updates.tokens_used); }
if (updates.cache_tokens !== undefined) { cols.push('cache_tokens'); vals.push(updates.cache_tokens); }
if (updates.reasoning_tokens !== undefined) { cols.push('reasoning_tokens'); vals.push(updates.reasoning_tokens); }
if (updates.error !== undefined) { cols.push('error'); vals.push(updates.error); }
if (updates.outcome !== undefined) { cols.push('outcome'); vals.push(updates.outcome); }
if (cols.length === 0) {
const [row] = await sql<ToolTrace[]>`SELECT * FROM tool_traces WHERE id = ${id}`;
return row ?? null;
}
const setClause = cols.map((c, i) => `${c} = $${i + 1}`).join(', ');
const [row] = await sql.unsafe<ToolTrace[]>(
`UPDATE tool_traces SET ${setClause} WHERE id = $${cols.length + 1} RETURNING *`,
[...vals, id],
);
return row ?? null;
}

View File

@@ -0,0 +1,132 @@
/**
* vWhale: run_command tool. Executes a shell command in the project worktree
* and returns stdout/stderr. Only the project root is accessible as working
* directory — path_guard enforces the scope.
*
* Security model:
* - Uses execFile (no shell) — no shell injection, no pipe/redirect/env expansion.
* - args passed as array, never a string.
* - 30s timeout default, configure per-call.
* - 32KB output cap with truncation (same pattern as web_fetch.ts).
* - Working directory restricted to project root via path_guard.
* - No background processes allowed (waits for completion).
*/
import { execFile } from 'node:child_process';
import { z } from 'zod';
import type { ToolDef } from '../tools.js';
const RunCommandInput = z.object({
command: z.string().min(1).max(256),
args: z.array(z.string()).default([]),
description: z.string().max(256).optional(),
timeout_ms: z.number().int().positive().max(120_000).optional(),
});
export type RunCommandInputT = z.infer<typeof RunCommandInput>;
const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_OUTPUT_CHARS = 32_000;
export type RunCommandOutput =
| {
command: string;
args: string[];
exit_code: number;
stdout: string;
stderr: string;
truncated: boolean;
duration_ms: number;
}
| {
error: string;
reason: string;
};
export async function executeRunCommand(
input: RunCommandInputT,
projectRoot: string,
): Promise<RunCommandOutput> {
const timeoutMs = input.timeout_ms ?? DEFAULT_TIMEOUT_MS;
const startTime = Date.now();
return new Promise((resolve) => {
const child = execFile(
input.command,
input.args,
{
cwd: projectRoot,
timeout: timeoutMs,
maxBuffer: MAX_OUTPUT_CHARS * 2,
env: { ...process.env },
},
(err, stdout, stderr) => {
const durationMs = Date.now() - startTime;
// Truncate output if needed
const truncated = stdout.length + stderr.length > MAX_OUTPUT_CHARS;
const cappedStdout = truncated ? stdout.slice(0, MAX_OUTPUT_CHARS) : stdout;
const cappedStderr = truncated ? stderr.slice(0, Math.max(MAX_OUTPUT_CHARS - cappedStdout.length, 0)) : stderr;
const exitCode = err?.code === 'ENOENT' ? -1 : (err as Error & { code?: number })?.code ?? 0;
resolve({
command: input.command,
args: input.args,
exit_code: typeof exitCode === 'number' ? exitCode : 1,
stdout: cappedStdout,
stderr: cappedStderr,
truncated,
duration_ms: durationMs,
});
},
);
});
}
export const runCommand: ToolDef<RunCommandInputT> = {
name: 'run_command',
description:
'Run a shell command in the project workspace and return stdout + stderr. ' +
'The command runs in the project root directory. ' +
'Use for: building, testing, linting, git operations, running scripts. ' +
'Output is capped at 32KB. Timeout defaults to 30s (max 120s). ' +
'Security: args are passed as array (no shell injection). No background processes.',
inputSchema: RunCommandInput as unknown as z.ZodType<RunCommandInputT>,
jsonSchema: {
type: 'function',
function: {
name: 'run_command',
description:
'Execute a command in the project workspace. ' +
'Use for builds, tests, linting, git commands, and scripts. ' +
'The process runs with a 30s timeout and 32KB output cap.',
parameters: {
type: 'object',
properties: {
command: {
type: 'string',
description: 'Command to execute (e.g. pnpm, npm, npx, node, git, ls, cat).',
},
args: {
type: 'array',
items: { type: 'string' },
description: 'Arguments as array (e.g. ["run", "build"]). Never embedded in a shell string.',
},
description: {
type: 'string',
description: 'Optional human-readable description of what this command does.',
},
timeout_ms: {
type: 'integer',
description: 'Timeout in milliseconds. Default 30000, max 120000.',
},
},
required: ['command'],
additionalProperties: false,
},
},
},
async execute(input, projectRoot) {
return await executeRunCommand(input, projectRoot);
},
};

View File

@@ -23,6 +23,7 @@ import {
getCodeImpact, getCodeImpact,
getTypeInfo, getTypeInfo,
getCodeMap, getCodeMap,
getWikiArticle,
} from './codecontext/index.js'; } from './codecontext/index.js';
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired // v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the // with the pause-on-pending-grant branch in inference/tool-phase.ts and the
@@ -31,6 +32,14 @@ import { requestReadAccess } from '../request_read_access.js';
// v2.6.x: read-only tool that reads a tab's transcript by its session-scoped // v2.6.x: read-only tool that reads a tab's transcript by its session-scoped
// tab number. Needs DB/session context (ToolExecCtx 4th arg). // tab number. Needs DB/session context (ToolExecCtx 4th arg).
import { readTabByNumber } from '../read_tab_by_number.js'; import { readTabByNumber } from '../read_tab_by_number.js';
// v2.x: memory management tools. file-based store with optional CoreTier
// (SQLite FTS5 + vector) hybrid search backend.
import { extractMemoryTool } from './extract_memory.js';
import { manageMemoryTool } from './manage_memory.js';
import { searchMemoryTool } from './search_memory.js';
// vWhale: command execution tool. Spawns processes in the project worktree
// with timeout and output cap. No shell — args are passed as array.
import { runCommand } from './execute-command.js';
// v1.13.3: alpha-sorted by tool.name at module load. llama.cpp's prompt // v1.13.3: alpha-sorted by tool.name at module load. llama.cpp's prompt
// cache hits on byte-identical prefixes; the tool list lives near the top // cache hits on byte-identical prefixes; the tool list lives near the top
@@ -85,6 +94,17 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
getCodeImpact as ToolDef<unknown>, getCodeImpact as ToolDef<unknown>,
getTypeInfo as ToolDef<unknown>, getTypeInfo as ToolDef<unknown>,
getCodeMap as ToolDef<unknown>, getCodeMap as ToolDef<unknown>,
// v2.8.14-domain2-phase3: wiki mode + token-efficient scanning.
getWikiArticle as ToolDef<unknown>,
// v2.x: memory management tools. File-based store with optional CoreTier
// (SQLite FTS5 + vector) hybrid search backend.
extractMemoryTool as ToolDef<unknown>,
manageMemoryTool as ToolDef<unknown>,
searchMemoryTool as ToolDef<unknown>,
// vWhale: command execution. Spawns processes in the project worktree.
// Read-write; use with guard: restricted to project root via path_guard,
// no shell injection (execFile, not exec).
runCommand as ToolDef<unknown>,
].sort((a, b) => a.name.localeCompare(b.name)); ].sort((a, b) => a.name.localeCompare(b.name));
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries( export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(

View File

@@ -34,6 +34,10 @@ import type {
SessionAnalyticsRow, SessionAnalyticsRow,
ContextWindowStats, ContextWindowStats,
TokenBreakdownAgg, TokenBreakdownAgg,
ToolTraceResponse,
MemoryEntry,
DailyMemoryEntry,
DreamEntry,
} from './types'; } from './types';
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by // v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by
@@ -340,6 +344,10 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ tool_call_id: toolCallId, decision }), body: JSON.stringify({ tool_call_id: toolCallId, decision }),
}), }),
getTraces: (chatId: string, limit = 50, offset = 0) =>
request<ToolTraceResponse>(
`/api/chats/${chatId}/traces?limit=${limit}&offset=${offset}`,
),
}, },
messages: { messages: {
@@ -608,6 +616,22 @@ export const api = {
tokenBreakdown: () => request<{ categories: TokenBreakdownAgg[] }>('/api/coder/analytics/token-breakdown'), tokenBreakdown: () => request<{ categories: TokenBreakdownAgg[] }>('/api/coder/analytics/token-breakdown'),
}, },
// memory-browser-ui: topic-based memory, daily log, dream diaries.
memory: {
list: (projectId: string) =>
request<{ entries: MemoryEntry[] }>(
`/api/memory?project_id=${encodeURIComponent(projectId)}`,
),
daily: (projectId: string) =>
request<{ entries: DailyMemoryEntry[] }>(
`/api/memory/daily?project_id=${encodeURIComponent(projectId)}`,
),
dreams: (projectId: string) =>
request<{ entries: DreamEntry[] }>(
`/api/memory/dreams?project_id=${encodeURIComponent(projectId)}`,
),
},
settings: { settings: {
get: () => request<Record<string, unknown>>('/api/settings'), get: () => request<Record<string, unknown>>('/api/settings'),
patch: (body: Record<string, unknown>) => patch: (body: Record<string, unknown>) =>

View File

@@ -561,6 +561,14 @@ export type WsFrame =
} }
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string } | { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
| { type: 'chat_renamed'; chat_id: string; name: string } | { type: 'chat_renamed'; chat_id: string; name: string }
| {
type: 'agent_snapshot';
chat_id: string;
agent?: string | null;
model: string;
mode?: string | null;
turn_number: number;
}
// v1.11: published by services/compaction.ts after the new anchored // v1.11: published by services/compaction.ts after the new anchored
// summary row lands. Carries the new summary row id for diagnostics; the // summary row lands. Carries the new summary row id for diagnostics; the
// session-stream handler ignores the id and re-fetches the full message // session-stream handler ignores the id and re-fetches the full message
@@ -604,6 +612,31 @@ export type WsFrame =
run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
report?: string; report?: string;
} }
// tool trace frames: per-tool-call lifecycle tracking
| {
type: 'tool_trace_start';
trace_id: string;
message_id: string;
chat_id: string;
tool_name: string;
tool_input: Record<string, unknown>;
started_at: string;
}
| {
type: 'tool_trace_finish';
trace_id: string;
message_id: string;
chat_id: string;
tool_name: string;
tool_output?: string | null;
latency_ms?: number;
tokens_used?: number | null;
cache_tokens?: number | null;
reasoning_tokens?: number | null;
error?: string;
outcome?: string;
finished_at: string;
}
// arena frames: battle lifecycle + per-contestant streaming // arena frames: battle lifecycle + per-contestant streaming
| { | {
type: 'battle_started'; type: 'battle_started';
@@ -630,8 +663,64 @@ export type WsFrame =
winner_contestant_id?: string | null; winner_contestant_id?: string | null;
analysis_ready?: boolean; analysis_ready?: boolean;
cross_exam_id?: string; cross_exam_id?: string;
}
// streaming v2: channel-delta frames. Each carries a monotonic seq for
// out-of-order buffering and a channel discriminator; per-channel payloads
// map to the equivalent legacy frame types after reordering.
| {
type: 'channel_delta';
seq: number;
channel: 'text' | 'tool_call' | 'tool_result' | 'status' | 'error';
message_id?: string;
chat_id?: string;
content?: string;
tool_call?: ToolCall;
tool_message_id?: string;
tool_call_id?: string;
output?: unknown;
truncated?: boolean;
error?: string;
reason?: string;
status?: 'running' | 'complete' | 'cancelled' | 'failed';
tokens_used?: number | null;
ctx_used?: number | null;
ctx_max?: number | null;
cache_tokens?: number | null;
reasoning_tokens?: number | null;
started_at?: string | null;
finished_at?: string | null;
model?: string | null;
metadata?: MessageMetadata | null;
}; };
// tool traces: per-tool-call record returned by GET /api/chats/:id/traces.
export interface ToolTrace {
id: string;
session_id: string;
chat_id: string;
message_id: string | null;
turn_number: number;
tool_name: string;
tool_input: Record<string, unknown>;
tool_output: string | null;
started_at: string;
finished_at: string | null;
latency_ms: number | null;
tokens_used: number | null;
cache_tokens: number | null;
reasoning_tokens: number | null;
error: string | null;
outcome: string | null;
created_at: string;
}
export interface ToolTraceResponse {
data: ToolTrace[];
total: number;
limit: number;
offset: number;
}
// token-analyzer-ui: aggregate token/cost analytics types. // token-analyzer-ui: aggregate token/cost analytics types.
export interface AnalyticsSummary { export interface AnalyticsSummary {
total_input_tokens: number; total_input_tokens: number;
@@ -660,3 +749,21 @@ export interface TokenBreakdownAgg {
category: string; category: string;
total_tokens: number; total_tokens: number;
} }
// ── Memory browser types ────────────────────────────────────────────
export interface MemoryEntry {
id: string;
topic: string;
title: string;
content: string;
tags: string[];
}
export interface DailyMemoryEntry extends MemoryEntry {
date: string;
}
export interface DreamEntry {
date: string;
content: string;
}

View File

@@ -1,10 +1,14 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { Pin } from 'lucide-react';
import type { Chat, Message } from '@/api/types'; import type { Chat, Message } from '@/api/types';
import { MessageBubble } from './MessageBubble'; import { MessageBubble } from './MessageBubble';
import { ToolCallGroup } from './ToolCallGroup'; import { ToolCallGroup } from './ToolCallGroup';
import { ToolCallLine, type ToolRun } from './ToolCallLine'; import { ToolCallLine, type ToolRun } from './ToolCallLine';
import { AskUserInputCard } from './AskUserInputCard'; import { AskUserInputCard } from './AskUserInputCard';
import { RequestReadAccessCard } from './RequestReadAccessCard'; import { RequestReadAccessCard } from './RequestReadAccessCard';
import { MessageListErrorBoundary } from './MessageListErrorBoundary';
interface Props { interface Props {
messages: Message[]; messages: Message[];
@@ -142,27 +146,63 @@ function stampCapHits(items: RenderItem[]): RenderItem[] {
}); });
} }
const SCROLL_THRESHOLD_PX = 150;
export function MessageList({ messages, sessionChats }: Props) { export function MessageList({ messages, sessionChats }: Props) {
const endRef = useRef<HTMLDivElement>(null); const virtuosoRef = useRef<VirtuosoHandle>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true); const isNearBottomRef = useRef(true);
const renderedKeysRef = useRef(new Set<string>());
const prefersReducedMotionRef = useRef(false);
const [animateEnabled, setAnimateEnabled] = useState(true);
const [pinMessageId, setPinMessageId] = useState<string | null>(() => {
if (typeof window !== 'undefined') {
const hash = window.location.hash;
if (hash.startsWith('#pin=')) return hash.slice(5);
}
return null;
});
const renderItems = useMemo(() => stampCapHits(group(flatten(messages))), [messages]); const renderItems = useMemo(() => stampCapHits(group(flatten(messages))), [messages]);
const handleScroll = useCallback(() => { const pinIndex = useMemo(() => {
const el = scrollContainerRef.current; if (!pinMessageId) return -1;
if (!el) return; return renderItems.findIndex(
isNearBottomRef.current = (item) => item.kind === 'message' && item.message.id === pinMessageId,
el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD_PX; );
}, [pinMessageId, renderItems]);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
prefersReducedMotionRef.current = mq.matches;
const handler = (e: MediaQueryListEvent) => {
prefersReducedMotionRef.current = e.matches;
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (isNearBottomRef.current) { const handler = () => {
endRef.current?.scrollIntoView({ block: 'end' }); const hash = window.location.hash;
if (hash.startsWith('#pin=')) {
setPinMessageId(hash.slice(5));
} else {
setPinMessageId(null);
} }
}, [messages]); };
window.addEventListener('hashchange', handler);
return () => window.removeEventListener('hashchange', handler);
}, []);
const atBottomStateChange = useCallback((atBottom: boolean) => {
isNearBottomRef.current = atBottom;
setAnimateEnabled(atBottom);
}, []);
const scrollToPin = useCallback(() => {
if (pinIndex >= 0 && virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({ index: pinIndex, align: 'center' });
}
}, [pinIndex]);
if (messages.length === 0) { if (messages.length === 0) {
return ( return (
@@ -173,46 +213,78 @@ export function MessageList({ messages, sessionChats }: Props) {
} }
return ( return (
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef} onScroll={handleScroll}> <MessageListErrorBoundary>
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4"> <div className="flex-1 flex flex-col">
{renderItems.map((item) => { {pinMessageId && pinIndex >= 0 && (
if (item.kind === 'message') { <div className="shrink-0 flex items-center gap-2 px-4 py-1.5 bg-primary/10 border-b border-primary/20 text-xs text-primary">
<Pin className="size-3" />
<span>Pinned message</span>
<button
type="button"
onClick={scrollToPin}
className="ml-auto underline hover:no-underline"
>
Jump to pinned
</button>
</div>
)}
<Virtuoso
ref={virtuosoRef}
className="flex-1"
data={renderItems}
followOutput="auto"
overscan={5}
atBottomStateChange={atBottomStateChange}
itemContent={(index, item) => {
const key = item.kind === 'message' ? `msg-${item.message.id}` : item.key;
const isNew = !renderedKeysRef.current.has(key);
if (isNew) renderedKeysRef.current.add(key);
const reducedMotion = prefersReducedMotionRef.current;
const delay = isNew && !reducedMotion ? Math.min(index * 0.04, 0.5) : 0;
const shouldAnimate = isNew && animateEnabled;
return ( return (
<div
className="max-w-[1000px] mx-auto w-full px-6 py-2"
id={item.kind === 'message' ? `msg-${item.message.id}` : undefined}
>
<motion.div
initial={shouldAnimate ? { opacity: 0, y: 8 } : false}
animate={{ opacity: 1, y: 0 }}
transition={delay > 0 ? { duration: 0.2, delay } : { duration: 0 }}
>
{item.kind === 'message' ? (
<MessageBubble <MessageBubble
key={item.message.id}
message={item.message} message={item.message}
sessionChats={sessionChats} sessionChats={sessionChats}
capHitInfo={item.capHitInfo} capHitInfo={item.capHitInfo}
/> />
); ) : item.kind === 'tool_run' ? (
} item.run.call.name === 'ask_user_input' ? (
if (item.kind === 'tool_run') {
if (item.run.call.name === 'ask_user_input') {
return (
<AskUserInputCard <AskUserInputCard
key={item.key}
toolCall={item.run.call} toolCall={item.run.call}
toolResult={item.run.result} toolResult={item.run.result}
chatId={item.chatId} chatId={item.chatId}
/> />
); ) : item.run.call.name === 'request_read_access' ? (
}
if (item.run.call.name === 'request_read_access') {
return (
<RequestReadAccessCard <RequestReadAccessCard
key={item.key}
toolCall={item.run.call} toolCall={item.run.call}
toolResult={item.run.result} toolResult={item.run.result}
chatId={item.chatId} chatId={item.chatId}
/> />
) : (
<ToolCallLine run={item.run} />
)
) : (
<ToolCallGroup runs={item.runs} />
)}
</motion.div>
</div>
); );
} }}
return <ToolCallLine key={item.key} run={item.run} />; />
}
return <ToolCallGroup key={item.key} runs={item.runs} />;
})}
<div ref={endRef} />
</div>
</div> </div>
</MessageListErrorBoundary>
); );
} }

View File

@@ -0,0 +1,188 @@
import { useMemo } from 'react';
import { Clock, Cpu, Hash, Layers, RefreshCw, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { Message } from '@/api/types';
interface TurnEntry {
message: Message;
turnNumber: number;
elapsed: string;
toolCallCount: number;
}
interface Props {
messages: Message[];
chatId: string;
onClose: () => void;
onScrollToMessage: (messageId: string) => void;
}
function formatElapsed(startedAt: string | null, finishedAt: string | null): string {
if (!startedAt || !finishedAt) return '—';
const start = new Date(startedAt).getTime();
const end = new Date(finishedAt).getTime();
if (Number.isNaN(start) || Number.isNaN(end)) return '—';
const ms = end - start;
if (ms < 0) return '—';
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
const mins = Math.floor(ms / 60_000);
const secs = Math.round((ms % 60_000) / 1000);
return `${mins}m ${secs}s`;
}
/**
* SessionTimeline — vertical timeline of assistant turns in a chat.
*
* Renders a side-panel overlay with each turn's model, tokens, duration,
* and tool-call count. Clicking a turn scrolls the main chat to that
* message. The latest turn shows a "Scroll to latest" restore button.
*/
export function SessionTimeline({ messages, onClose, onScrollToMessage }: Props) {
const turns = useMemo<TurnEntry[]>(() => {
const assistantMsgs = messages.filter(
(m) => m.role === 'assistant' && m.status === 'complete',
);
return assistantMsgs.map((message, i) => ({
message,
turnNumber: i + 1,
elapsed: formatElapsed(message.started_at, message.finished_at),
toolCallCount: message.tool_calls?.length ?? 0,
}));
}, [messages]);
const latestTurnId = turns.length > 0 ? turns[turns.length - 1]!.message.id : null;
return (
<div className="absolute inset-y-0 right-0 w-80 z-20 bg-background border-l border-border shadow-xl flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border shrink-0">
<h3 className="text-sm font-semibold">Session Timeline</h3>
<Button variant="ghost" size="icon-xs" onClick={onClose} aria-label="Close timeline">
<X size={14} />
</Button>
</div>
{/* Timeline entries */}
<div className="flex-1 overflow-y-auto px-3 py-3">
{turns.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-8">
No assistant turns yet.
</div>
) : (
<div className="relative">
{turns.map((turn, i) => {
const isLatest = turn.message.id === latestTurnId;
return (
<div key={turn.message.id} className="relative flex gap-3 pb-4 last:pb-0">
{/* Vertical connector line */}
{i < turns.length - 1 && (
<div className="absolute left-[11px] top-5 bottom-0 w-px bg-border" />
)}
{/* Timeline dot button */}
<button
type="button"
onClick={() => onScrollToMessage(turn.message.id)}
className="relative flex-shrink-0 mt-1 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-full"
aria-label={`Scroll to turn ${turn.turnNumber}`}
>
<div
className={cn(
'size-[22px] rounded-full border-2 flex items-center justify-center',
isLatest
? 'border-primary bg-primary/10'
: 'border-muted-foreground/30 bg-background',
)}
>
<div
className={cn(
'size-2 rounded-full',
isLatest ? 'bg-primary' : 'bg-muted-foreground/50',
)}
/>
</div>
</button>
{/* Content card */}
<div className="flex-1 min-w-0">
<div
className="rounded-lg border border-border bg-card p-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
onClick={() => onScrollToMessage(turn.message.id)}
>
{/* Turn number + latest badge */}
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-foreground">
Turn {turn.turnNumber}
</span>
{isLatest && (
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full leading-none">
Latest
</span>
)}
</div>
{/* Model name */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1.5">
<Cpu size={11} className="shrink-0" />
<span className="truncate">{turn.message.model ?? 'Unknown model'}</span>
</div>
{/* Token count with breakdown */}
{turn.message.tokens_used != null && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1 flex-wrap">
<Hash size={11} className="shrink-0" />
<span>{turn.message.tokens_used.toLocaleString()} total</span>
{turn.message.cache_tokens != null && turn.message.cache_tokens > 0 && (
<span className="text-blue-500 dark:text-blue-400">
({turn.message.cache_tokens.toLocaleString()} cache)
</span>
)}
{turn.message.reasoning_tokens != null && turn.message.reasoning_tokens > 0 && (
<span className="text-amber-500 dark:text-amber-400">
({turn.message.reasoning_tokens.toLocaleString()} reasoning)
</span>
)}
</div>
)}
{/* Duration + tool calls */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock size={11} />
{turn.elapsed}
</span>
{turn.toolCallCount > 0 && (
<span className="inline-flex items-center gap-1">
<Layers size={11} />
{turn.toolCallCount} tool call{turn.toolCallCount !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
{/* Restore button for latest turn */}
{isLatest && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onScrollToMessage(turn.message.id);
}}
className="mt-1.5 w-full inline-flex items-center justify-center gap-1 text-[11px] font-medium text-primary hover:text-primary/80 transition-colors py-1 rounded-md hover:bg-primary/5"
>
<RefreshCw size={11} />
Scroll to latest
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,251 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
import { api } from '@/api/client';
import type { ToolTrace } from '@/api/types';
interface Props {
chatId: string;
}
// Max latency used as the 100% reference for the bar visualization
const MAX_LATENCY_REF = 30_000; // 30s
function latencyBarWidth(latencyMs: number | null): number {
if (latencyMs == null) return 0;
return Math.min(latencyMs / MAX_LATENCY_REF, 1);
}
function TraceRow({ trace }: { trace: ToolTrace }) {
const [expanded, setExpanded] = useState(false);
const isError = trace.outcome !== null && trace.outcome !== 'success';
const barWidth = latencyBarWidth(trace.latency_ms);
const latencyLabel =
trace.latency_ms != null
? trace.latency_ms >= 1000
? `${(trace.latency_ms / 1000).toFixed(1)}s`
: `${trace.latency_ms}ms`
: null;
return (
<div className="border-b border-border/40 last:border-0">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-2 w-full text-left px-2 py-1.5 hover:bg-muted/40 text-[11px]"
>
<span className="shrink-0 text-muted-foreground">
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
</span>
<span className="font-medium truncate min-w-0">
{trace.tool_name}
</span>
{isError && (
<span className="shrink-0 text-destructive" title={trace.error ?? 'error'}>
<AlertCircle size={10} />
</span>
)}
<span className="shrink-0 text-muted-foreground font-mono tabular-nums min-w-[3rem] text-right">
{latencyLabel ?? '—'}
</span>
<span className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden min-w-[24px] max-w-[60px]">
<span
className="block h-full rounded-full bg-primary/30 transition-all"
style={{ width: `${barWidth * 100}%` }}
/>
</span>
{trace.tokens_used != null && trace.tokens_used > 0 && (
<span className="shrink-0 text-muted-foreground font-mono tabular-nums">
{trace.tokens_used}t
</span>
)}
{trace.cache_tokens != null && trace.cache_tokens > 0 && (
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
c{trace.cache_tokens}
</span>
)}
{trace.reasoning_tokens != null && trace.reasoning_tokens > 0 && (
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
r{trace.reasoning_tokens}
</span>
)}
</button>
{expanded && (
<div className="px-3 pb-2 space-y-1.5 text-[11px] border-t border-border/40 pt-1.5">
<div>
<span className="text-muted-foreground font-medium">Input</span>
<pre className="mt-0.5 font-mono text-[10px] leading-relaxed text-muted-foreground bg-muted/30 rounded p-1.5 overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">
{JSON.stringify(trace.tool_input, null, 1)}
</pre>
</div>
{trace.tool_output != null && (
<div>
<span className="text-muted-foreground font-medium">Output</span>
<pre className="mt-0.5 font-mono text-[10px] leading-relaxed text-muted-foreground bg-muted/30 rounded p-1.5 overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">
{trace.tool_output.length > 2000
? `${trace.tool_output.slice(0, 2000)}`
: trace.tool_output}
</pre>
</div>
)}
{trace.error != null && (
<div className="text-destructive text-[10px] font-mono leading-relaxed bg-destructive/10 rounded p-1.5">
{trace.error}
</div>
)}
</div>
)}
</div>
);
}
function TraceGroup({ toolName, traces }: { toolName: string; traces: ToolTrace[] }) {
const [collapsed, setCollapsed] = useState(false);
const totalLatency = traces.reduce((sum, t) => sum + (t.latency_ms ?? 0), 0);
const totalTokens = traces.reduce((sum, t) => sum + (t.tokens_used ?? 0), 0);
const errorCount = traces.filter(
(t) => t.outcome !== null && t.outcome !== 'success',
).length;
return (
<div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="flex items-center gap-1.5 w-full text-left px-2 py-1 text-[11px] font-medium text-muted-foreground hover:bg-muted/30 sticky top-0 bg-background"
>
{collapsed ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
<span>{toolName}</span>
<span className="text-muted-foreground/60 font-mono tabular-nums">
×{traces.length}
</span>
{totalTokens > 0 && (
<span className="text-muted-foreground/60 font-mono tabular-nums text-[10px]">
{totalTokens}t
</span>
)}
{totalLatency > 0 && (
<span className="text-muted-foreground/60 font-mono tabular-nums text-[10px]">
{totalLatency >= 1000
? `${(totalLatency / 1000).toFixed(1)}s`
: `${totalLatency}ms`}
</span>
)}
{errorCount > 0 && (
<span className="ml-auto text-destructive text-[10px] font-medium">
{errorCount} error{errorCount > 1 ? 's' : ''}
</span>
)}
</button>
{!collapsed && traces.map((trace) => (
<TraceRow key={trace.id} trace={trace} />
))}
</div>
);
}
export function TraceViewer({ chatId }: Props) {
const [open, setOpen] = useState(false);
const [traces, setTraces] = useState<ToolTrace[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchTraces = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await api.chats.getTraces(chatId);
setTraces(res.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'failed to load traces');
} finally {
setLoading(false);
}
}, [chatId]);
useEffect(() => {
if (open) {
void fetchTraces();
}
}, [open, fetchTraces]);
const groups = useMemo(() => {
const map = new Map<string, ToolTrace[]>();
for (const t of traces) {
const existing = map.get(t.tool_name);
if (existing) {
existing.push(t);
} else {
map.set(t.tool_name, [t]);
}
}
return map;
}, [traces]);
const totalCount = traces.length;
const errorCount = traces.filter(
(t) => t.outcome !== null && t.outcome !== 'success',
).length;
return (
<div className="border-t">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-[11px] font-medium text-muted-foreground hover:bg-muted/20"
>
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>Tool traces</span>
{totalCount > 0 && (
<span className="font-mono tabular-nums text-muted-foreground/60">
{totalCount}
</span>
)}
{errorCount > 0 && (
<span className="text-destructive ml-auto text-[10px] font-medium">
{errorCount} error{errorCount > 1 ? 's' : ''}
</span>
)}
{loading && (
<span className="ml-auto inline-block w-1.5 h-3 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</button>
{open && (
<div className="max-h-80 overflow-y-auto border-t border-border/40">
{loading && traces.length === 0 && (
<div className="px-3 py-4 text-[11px] text-muted-foreground text-center">
Loading traces
</div>
)}
{error && (
<div className="px-3 py-2 text-[11px] text-destructive">
{error}
<button
type="button"
onClick={() => void fetchTraces()}
className="ml-2 underline hover:no-underline"
>
retry
</button>
</div>
)}
{!loading && !error && traces.length === 0 && (
<div className="px-3 py-4 text-[11px] text-muted-foreground text-center">
No tool traces yet.
</div>
)}
{traces.length > 0 && (
<div className="divide-y divide-border/40">
{Array.from(groups.entries()).map(([toolName, groupTraces]) => (
<TraceGroup
key={toolName}
toolName={toolName}
traces={groupTraces}
/>
))}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,11 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Pencil, Send, X } from 'lucide-react'; import { History, Pencil, Send, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api/client'; import { api } from '@/api/client';
import { useSessionStream } from '@/hooks/useSessionStream'; import { useSessionStream } from '@/hooks/useSessionStream';
import { MessageList } from '@/components/MessageList'; import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput'; import { ChatInput } from '@/components/ChatInput';
import { StaleStreamBanner } from '@/components/StaleStreamBanner'; import { StaleStreamBanner } from '@/components/StaleStreamBanner';
import { SessionTimeline } from '@/components/SessionTimeline';
import { TraceViewer } from '@/components/TraceViewer';
import { sendToChat } from '@/lib/events'; import { sendToChat } from '@/lib/events';
interface Props { interface Props {
@@ -25,6 +27,7 @@ interface Props {
export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled }: Props) { export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled }: Props) {
const stream = useSessionStream(sessionId); const stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null); const lastErrorRef = useRef<string | null>(null);
const [showTimeline, setShowTimeline] = useState(false);
const [queue, setQueue] = useState<{ id: string; text: string }[]>([]); const [queue, setQueue] = useState<{ id: string; text: string }[]>([]);
const queueIdRef = useRef(0); const queueIdRef = useRef(0);
const processingRef = useRef(false); const processingRef = useRef(false);
@@ -203,11 +206,41 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
} }
} }
const handleScrollToMessage = useCallback((messageId: string) => {
const el = document.getElementById(`msg-${messageId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, []);
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0 relative">
{chatMessages.length > 0 && (
<div className="absolute top-2 right-2 z-10">
<button
type="button"
onClick={() => setShowTimeline((v) => !v)}
className={`
inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium
transition-colors border
${showTimeline
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:bg-muted hover:text-foreground'
}
`}
aria-label={showTimeline ? 'Close timeline' : 'Open timeline'}
>
<History size={12} />
Timeline
</button>
</div>
)}
{/* v1.11.5: ContextBar moved into ChatInput (above the agent picker). */} {/* v1.11.5: ContextBar moved into ChatInput (above the agent picker). */}
<MessageList messages={chatMessages} sessionChats={sessionChats} /> <MessageList messages={chatMessages} sessionChats={sessionChats} />
<TraceViewer chatId={chatId} />
{/* Queued messages */} {/* Queued messages */}
{queue.length > 0 && ( {queue.length > 0 && (
<div className="border-t"> <div className="border-t">
@@ -275,6 +308,16 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
messages={chatMessages} messages={chatMessages}
modelContextLimit={modelContextLimit} modelContextLimit={modelContextLimit}
/> />
{/* Timeline overlay panel */}
{showTimeline && (
<SessionTimeline
messages={chatMessages}
chatId={chatId}
onClose={() => setShowTimeline(false)}
onScrollToMessage={handleScrollToMessage}
/>
)}
</div> </div>
); );
} }

View File

@@ -16,6 +16,133 @@ interface State {
error: string | null; error: string | null;
} }
type Channel = 'text' | 'tool_call' | 'tool_result' | 'status' | 'error';
// Per-channel out-of-order frame buffer with contiguous-seq flush logic.
// Stores incoming channel_delta frames and releases them only when seq
// becomes contiguous with the expected next value.
class ChannelBuffer {
private expectedSeq = 0;
private buffer = new Map<number, ChannelDeltaWsFrame>();
push(frame: ChannelDeltaWsFrame): ChannelDeltaWsFrame[] {
if (frame.seq < this.expectedSeq) {
return [];
}
if (frame.seq === this.expectedSeq) {
this.expectedSeq++;
const flushed = [frame];
while (this.buffer.has(this.expectedSeq)) {
const next = this.buffer.get(this.expectedSeq)!;
this.buffer.delete(this.expectedSeq);
this.expectedSeq++;
flushed.push(next);
}
return flushed;
}
this.buffer.set(frame.seq, frame);
return [];
}
get expectedNextSeq(): number {
return this.expectedSeq;
}
get bufferedCount(): number {
return this.buffer.size;
}
reset(seq = 0) {
this.expectedSeq = seq;
this.buffer.clear();
}
}
type ChannelDeltaWsFrame = WsFrame & { type: 'channel_delta' };
// Converts a flushed channel_delta into the equivalent legacy frame so the
// existing applyFrame reducer handles the per-message mutation. Status
// deltas are handled separately (they may need to create the message first
// and apply throughput metadata independently of terminal status).
function channelDeltaToLegacyFrame(delta: ChannelDeltaWsFrame): WsFrame | null {
switch (delta.channel) {
case 'text':
return { type: 'delta', message_id: delta.message_id!, content: delta.content! };
case 'tool_call':
return { type: 'tool_call', message_id: delta.message_id!, tool_call: delta.tool_call! };
case 'tool_result':
return {
type: 'tool_result',
tool_message_id: delta.tool_message_id!,
chat_id: delta.chat_id,
tool_call_id: delta.tool_call_id!,
output: delta.output,
truncated: delta.truncated!,
...(delta.error ? { error: delta.error } : {}),
};
case 'error':
return {
type: 'error',
message_id: delta.message_id,
chat_id: delta.chat_id,
error: delta.error!,
...(delta.reason ? { reason: delta.reason as never } : {}),
};
case 'status':
return null;
}
}
// Apply a flushed status channel_delta to state. Status deltas carry both
// intermediate throughput metadata (tokens_used, ctx_used, model, etc.)
// and optional terminal transitions (complete / cancelled / failed).
function applyStatusDelta(state: State, delta: ChannelDeltaWsFrame): State {
const { message_id, chat_id, status, channel: _c, seq: _s, type: _t, ...meta } = delta;
if (!message_id) return state;
let next = state;
const exists = next.messages.some((m) => m.id === message_id);
if (!exists && status === 'running') {
next = applyFrame(next, {
type: 'message_started',
message_id,
chat_id,
role: 'assistant',
});
}
const metaFields: Record<string, unknown> = {};
if (meta.tokens_used !== undefined) metaFields.tokens_used = meta.tokens_used;
if (meta.ctx_used !== undefined) metaFields.ctx_used = meta.ctx_used;
if (meta.ctx_max !== undefined) metaFields.ctx_max = meta.ctx_max;
if (meta.cache_tokens !== undefined) metaFields.cache_tokens = meta.cache_tokens;
if (meta.reasoning_tokens !== undefined) metaFields.reasoning_tokens = meta.reasoning_tokens;
if (meta.started_at !== undefined) metaFields.started_at = meta.started_at;
if (meta.finished_at !== undefined) metaFields.finished_at = meta.finished_at;
if (meta.model !== undefined) metaFields.model = meta.model;
if (meta.metadata !== undefined) metaFields.metadata = meta.metadata;
if (Object.keys(metaFields).length > 0) {
next = {
...next,
messages: next.messages.map((m) =>
m.id === message_id ? { ...m, ...metaFields } : m,
),
};
}
if (status === 'complete' || status === 'cancelled' || status === 'failed') {
next = applyFrame(next, {
type: 'message_complete',
message_id,
chat_id,
status,
});
}
return next;
}
function applyFrame(state: State, frame: WsFrame): State { function applyFrame(state: State, frame: WsFrame): State {
switch (frame.type) { switch (frame.type) {
case 'snapshot': { case 'snapshot': {
@@ -33,8 +160,6 @@ function applyFrame(state: State, frame: WsFrame): State {
kind: 'message', kind: 'message',
tool_calls: null, tool_calls: null,
tool_results: null, tool_results: null,
// v1.8.2: cap-hit sentinels arrive role='system' and are static, so
// skipping the streaming dot for them keeps the UI accurate.
status: frame.role === 'system' ? 'complete' : 'streaming', status: frame.role === 'system' ? 'complete' : 'streaming',
last_seq: 0, last_seq: 0,
tokens_used: null, tokens_used: null,
@@ -65,7 +190,7 @@ function applyFrame(state: State, frame: WsFrame): State {
const next = state.messages.map((m) => const next = state.messages.map((m) =>
m.id === frame.message_id m.id === frame.message_id
? { ...m, tool_calls: [...(m.tool_calls ?? []), frame.tool_call] } ? { ...m, tool_calls: [...(m.tool_calls ?? []), frame.tool_call] }
: m : m,
); );
return { ...state, messages: next }; return { ...state, messages: next };
} }
@@ -85,7 +210,7 @@ function applyFrame(state: State, frame: WsFrame): State {
}, },
status: 'complete' as const, status: 'complete' as const,
} }
: m : m,
); );
return { ...state, messages: next }; return { ...state, messages: next };
} }
@@ -132,19 +257,13 @@ function applyFrame(state: State, frame: WsFrame): State {
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}), ...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}), ...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
...(frame.model !== undefined ? { model: frame.model } : {}), ...(frame.model !== undefined ? { model: frame.model } : {}),
// v1.8.2: cap-hit sentinels (and future stamped metadata) ride
// in on this terminal frame so the reducer can attach it
// without waiting for a refetch.
...(frame.metadata !== undefined ? { metadata: frame.metadata } : {}), ...(frame.metadata !== undefined ? { metadata: frame.metadata } : {}),
} }
: m : m,
); );
return { ...state, messages: next }; return { ...state, messages: next };
} }
case 'usage': { case 'usage': {
// v1.12.2: live throughput. Side-effects into the module-level
// singleton consumed by ChatThroughput; no message-state mutation.
// chat_id is the optional ws-frame field; usage frames always include it.
if (frame.chat_id) { if (frame.chat_id) {
recordUsage(frame.chat_id, { recordUsage(frame.chat_id, {
completion_tokens: frame.completion_tokens, completion_tokens: frame.completion_tokens,
@@ -172,10 +291,6 @@ function applyFrame(state: State, frame: WsFrame): State {
return state; return state;
} }
case 'error': { case 'error': {
// v1.8.2: when the frame carries a structured reason, stamp it onto the
// failed message's metadata so the bubble can render specifics inline
// (the WS error frame is one-shot; refresh-safe rendering needs the
// value persisted on the message).
const errorMeta = frame.reason const errorMeta = frame.reason
? { kind: 'error' as const, error_reason: frame.reason, error_text: frame.error } ? { kind: 'error' as const, error_reason: frame.reason, error_text: frame.error }
: null; : null;
@@ -187,47 +302,53 @@ function applyFrame(state: State, frame: WsFrame): State {
status: 'failed' as const, status: 'failed' as const,
...(errorMeta ? { metadata: errorMeta } : {}), ...(errorMeta ? { metadata: errorMeta } : {}),
} }
: m : m,
) )
: state.messages; : state.messages;
return { ...state, messages: next, error: frame.error }; return { ...state, messages: next, error: frame.error };
} }
case 'compacted': { case 'compacted': {
// v1.11: side effects (refetch + toast) live in ws.onmessage; the return state;
// reducer just no-ops so TS exhaustiveness is satisfied without }
// duplicating async work inside a synchronous reducer. case 'agent_snapshot': {
return state; return state;
} }
case 'agent_status_updated': { case 'agent_status_updated': {
// agent-status-normalize (#10): coder-only frame consumed by CoderPane's
// own WS handler, not BooChat's native message reducer. No-op here to keep
// TS exhaustiveness satisfied (native sessions never emit it).
return state; return state;
} }
case 'flow_run_started': case 'flow_run_started':
case 'flow_run_step_updated': { case 'flow_run_step_updated': {
// Orchestrator frames consumed by OrchestratorPane's own subscription.
// No-op here to keep TS exhaustiveness satisfied.
return state; return state;
} }
case 'battle_started': case 'battle_started':
case 'contestant_updated': case 'contestant_updated':
case 'battle_updated': { case 'battle_updated': {
// Arena frames consumed by ArenaPane's own subscription. return state;
// No-op here to keep TS exhaustiveness satisfied. }
case 'channel_delta': {
return state;
}
default: {
return state; return state;
} }
} }
} }
// Matches useUserEvents — exponential backoff with the same ceiling so the
// two channels reconnect on the same cadence after a network handoff.
const RECONNECT_INITIAL_MS = 1000; const RECONNECT_INITIAL_MS = 1000;
const RECONNECT_MAX_MS = 30_000; const RECONNECT_MAX_MS = 30_000;
const CHANNEL_STALL_MS = 5000;
export function useSessionStream(sessionId: string | undefined) { export function useSessionStream(sessionId: string | undefined) {
const [state, setState] = useState<State>({ messages: [], connected: false, error: null }); const [state, setState] = useState<State>({ messages: [], connected: false, error: null });
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const channelBuffersRef = useRef<Map<Channel, ChannelBuffer>>(new Map());
const lastFrameTimeRef = useRef<Partial<Record<Channel, number>>>({});
// Reset channel buffers when session changes
useEffect(() => {
channelBuffersRef.current = new Map();
lastFrameTimeRef.current = {};
}, [sessionId]);
useEffect(() => { useEffect(() => {
if (!sessionId) return; if (!sessionId) return;
@@ -238,6 +359,73 @@ export function useSessionStream(sessionId: string | undefined) {
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectDelay = RECONNECT_INITIAL_MS; let reconnectDelay = RECONNECT_INITIAL_MS;
const getLastSeqPerChannel = () => {
const seqs: Partial<Record<Channel, number>> = {};
for (const [ch, buf] of channelBuffersRef.current) {
seqs[ch] = buf.expectedNextSeq;
}
return seqs;
};
const flushDeltaToState = (delta: ChannelDeltaWsFrame) => {
console.error('FDS', delta.channel, 'flushed');
if (delta.channel === 'status') {
setState((s) => applyStatusDelta(s, delta));
} else {
const legacy = channelDeltaToLegacyFrame(delta);
if (legacy) {
setState((s) => applyFrame(s, legacy));
}
}
};
const handleChannelDelta = (frame: ChannelDeltaWsFrame) => {
console.error('HCD', frame.channel, frame.seq, 'bufs', channelBuffersRef.current.size);
const buffers = channelBuffersRef.current;
let buffer = buffers.get(frame.channel);
if (!buffer) {
buffer = new ChannelBuffer();
buffers.set(frame.channel, buffer);
}
const flushed = buffer.push(frame);
if (flushed.length === 0) return;
for (const delta of flushed) {
flushDeltaToState(delta);
}
let emittedRefresh = false;
for (const delta of flushed) {
if (delta.channel === 'status' && (delta.status === 'complete' || delta.status === 'cancelled' || delta.status === 'failed')) {
emittedRefresh = true;
}
}
if (emittedRefresh) {
sessionEvents.emit({ type: 'git_diff_refresh' });
}
lastFrameTimeRef.current[frame.channel] = Date.now();
};
// Periodic channel stall check: if any channel has buffered frames
// but no progress for 5s, force a snapshot refetch.
let stallTimer: ReturnType<typeof setInterval> | null = null;
const startStallTimer = () => {
stallTimer = setInterval(() => {
const now = Date.now();
for (const [channel, buffer] of channelBuffersRef.current) {
if (buffer.bufferedCount === 0) continue;
const lastTime = lastFrameTimeRef.current[channel as Channel] ?? 0;
if (now - lastTime >= CHANNEL_STALL_MS) {
buffer.reset();
sessionEvents.emit({ type: 'refetch_messages' });
}
}
}, 1000);
};
const connect = () => { const connect = () => {
if (unmounted) return; if (unmounted) return;
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
@@ -248,13 +436,16 @@ export function useSessionStream(sessionId: string | undefined) {
ws.onopen = () => { ws.onopen = () => {
reconnectDelay = RECONNECT_INITIAL_MS; reconnectDelay = RECONNECT_INITIAL_MS;
setState((s) => ({ ...s, connected: true, error: null })); setState((s) => ({ ...s, connected: true, error: null }));
// Mid-stream reconnection protocol: send last known seq per channel
// so the server can replay deltas or fall back to a full snapshot.
const lastSeq = getLastSeqPerChannel();
ws.send(JSON.stringify({ type: 'reconnect', lastSeqPerChannel: lastSeq }));
startStallTimer();
}; };
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
// v1.13.11-a: Zod-validate every inbound frame. Fail-closed — invalid
// frames are logged and dropped. WsFrameSchema is the runtime guard;
// the hand-maintained WsFrame type stays as the narrowed dev-time
// shape (Zod uses OpaqueObject for nested types like Message[]). One
// cast bridges the two.
let raw: unknown; let raw: unknown;
try { try {
raw = JSON.parse(typeof ev.data === 'string' ? ev.data : ''); raw = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
@@ -272,13 +463,14 @@ export function useSessionStream(sessionId: string | undefined) {
} }
try { try {
const frame = validated.data as unknown as WsFrame; const frame = validated.data as unknown as WsFrame;
// v1.11: on a compaction completion, re-fetch the message list so
// the new summary row + the cohort of compacted_at-stamped older if (frame.type === 'channel_delta') {
// rows render correctly. We dispatch the fresh list as a synthetic console.error('RAW_PARSE', JSON.stringify(validated.data).slice(0, 200));
// 'snapshot' frame so the reducer's existing path handles state console.error('CD', frame.channel, frame.seq, JSON.stringify(frame).slice(0, 80));
// replacement (no need for a parallel "refetched" path). handleChannelDelta(frame);
// The toast is purely UX feedback; missing it would still leave return;
// the chat in a valid state. }
if (frame.type === 'compacted') { if (frame.type === 'compacted') {
toast.success('Context compacted to free space'); toast.success('Context compacted to free space');
void api.messages void api.messages
@@ -291,8 +483,9 @@ export function useSessionStream(sessionId: string | undefined) {
}); });
return; return;
} }
setState((s) => applyFrame(s, frame)); setState((s) => applyFrame(s, frame));
// Trigger git diff refresh after each completed assistant turn.
if (frame.type === 'message_complete') { if (frame.type === 'message_complete') {
sessionEvents.emit({ type: 'git_diff_refresh' }); sessionEvents.emit({ type: 'git_diff_refresh' });
} }
@@ -300,15 +493,18 @@ export function useSessionStream(sessionId: string | undefined) {
console.warn('bad ws frame', err); console.warn('bad ws frame', err);
} }
}; };
// v1.8.1: WS errors no longer surface as user-facing toasts here. The
// user-channel hook (useUserEvents) owns the debounced "reconnecting…"
// UI; this channel just reconnects silently on the same backoff.
ws.onerror = () => { ws.onerror = () => {
try { ws.close(); } catch {} try { ws.close(); } catch {}
}; };
ws.onclose = () => { ws.onclose = () => {
if (unmounted) return; if (unmounted) return;
setState((s) => ({ ...s, connected: false })); setState((s) => ({ ...s, connected: false }));
if (stallTimer) {
clearInterval(stallTimer);
stallTimer = null;
}
const delay = reconnectDelay; const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS); reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
reconnectTimer = setTimeout(connect, delay); reconnectTimer = setTimeout(connect, delay);
@@ -320,6 +516,7 @@ export function useSessionStream(sessionId: string | undefined) {
return () => { return () => {
unmounted = true; unmounted = true;
if (reconnectTimer) clearTimeout(reconnectTimer); if (reconnectTimer) clearTimeout(reconnectTimer);
if (stallTimer) clearInterval(stallTimer);
const ws = wsRef.current; const ws = wsRef.current;
wsRef.current = null; wsRef.current = null;
if (ws) try { ws.close(); } catch {} if (ws) try { ws.close(); } catch {}

View File

@@ -1,44 +0,0 @@
# v2.8 — boocontext sidecar container.
# Multi-stage build: Go shim from golang:1.24-alpine, boocontext MCP aggregator
# from node:20-alpine, then an alpine:3.20 runtime holding both.
#
# The shim spawns boocontext as a child MCP process over stdio NDJSON,
# translating HTTP requests to MCP tools/call.
#
# To stage the fork source for a Docker build:
# tar -czf codecontext/fork.tar.gz -C /opt/forks/boocontext \
# --exclude=.git --exclude=node_modules --exclude=dist
# Stage 1: Go shim builder
FROM golang:1.24-alpine AS shim-builder
WORKDIR /build/shim
RUN apk add --no-cache ca-certificates
COPY go.mod ./
COPY shim.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /build/shim-bin ./
# Stage 2: boocontext MCP builder (pnpm project)
FROM node:20-alpine AS boocontext-builder
WORKDIR /build/boocontext
RUN apk add --no-cache git python3 make g++ ca-certificates
RUN npm install -g pnpm@9 --silent
COPY fork.tar.gz /build/fork.tar.gz
RUN mkdir -p /build/boocontext && tar -xzf /build/fork.tar.gz -C /build/boocontext
WORKDIR /build/boocontext
RUN pnpm install --frozen-lockfile && pnpm run build
# Stage 3: Runtime
FROM alpine:3.20
# uv intentionally not installed — container network blocks astral.sh.
# tree-sitter-analyzer child server (uvx) won't start in-container, but
# boocontext logs a graceful warning; TSA-backed tools fall through.
RUN apk add --no-cache ca-certificates nodejs
COPY --from=shim-builder /build/shim-bin /usr/local/bin/shim
COPY --from=boocontext-builder /build/boocontext/dist /usr/local/lib/boocontext/dist
COPY --from=boocontext-builder /build/boocontext/node_modules /usr/local/lib/boocontext/node_modules
COPY --from=boocontext-builder /build/boocontext/package.json /usr/local/lib/boocontext/package.json
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
CMD wget -qO- http://localhost:8080/health || exit 1
ENTRYPOINT ["/usr/local/bin/shim"]

View File

@@ -7,7 +7,6 @@ services:
- "100.114.205.53:9500:3000" - "100.114.205.53:9500:3000"
env_file: .env env_file: .env
environment: environment:
CODECONTEXT_URL: http://codecontext:8080
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
BOOCODER_URL: http://100.114.205.53:9502 BOOCODER_URL: http://100.114.205.53:9502
@@ -91,48 +90,6 @@ services:
networks: networks:
- boocode_net - boocode_net
# v1.12 Track B: codecontext sidecar. Stdio MCP server wrapped by a small
# HTTP shim (see ./codecontext/). No host port — reached from boocode at
# http://codecontext:8080 over the boocode_net bridge.
#
# DEPRECATED (Phase 4, Domain 2, v2.8.14): Superseded by the boocontext
# MCP server. The 16 codecontext tool wrappers still use this sidecar via
# HTTP but should route through the boocontext MCP instead. Keep the
# service running for backward compatibility until all callers migrate.
# Remove this block after full migration — see codecontext_client.ts and
# factory.ts for deprecation markers.
#
# Mounts /opt:/opt:ro (not just /opt/projects:ro): BooCode projects live
# at /opt/<slug> on the host, not exclusively under /opt/projects. The
# mount must cover anywhere a project.path could resolve to. Read-only
# because codecontext only analyzes — never writes. The model can't
# arbitrarily set target_dir to a sensitive subtree because the B.2
# wrappers validate target_dir against project.path before calling the
# shim, and the shim isn't reachable from outside boocode_net.
codecontext:
build:
context: ./codecontext
container_name: boocode_codecontext
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
environment:
CODECONTEXT_CHILD: node /usr/local/lib/boocontext/dist/index.js --mcp
TYPE_INJECT_MCP_PATH: /opt/type-inject/packages/mcp/dist/index.js
TREE_SITTER_MCP_CMD: uvx
TREE_SITTER_MCP_ARGS: --from tree-sitter-analyzer[mcp] tree-sitter-analyzer-mcp
networks:
- boocode_net
volumes:
- /opt:/opt:ro
- /opt/forks:/opt/forks:ro
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
volumes: volumes:
boocode_pgdata: boocode_pgdata:

View File

@@ -0,0 +1,107 @@
# Paseo-like Orchestrator — Trace Observability, Dynamic Workflows & Agent Runtime
**Status:** Proposed
**Epic:** paseo-orchestrator
**Depends on:** v2.7.17-orchestrator
## Why
BooCode's Orchestrator (v2.7.17) runs deterministic Han analysis flows — but it's a fixed pipeline, not a general-purpose agent runtime. Every tool call is opaque: no timing, no cost breakdown, no replay. Sessions evaporate on browser refresh. Workflows are hardcoded. Subagents block until completion. And there's zero visibility into cache efficiency on DeepSeek — despite prompt caching being a major cost lever.
The current architecture treats the LLM as a black box and the agent as a one-shot transaction. To move from "read-only chat" to a **Paseo-style thin-client orchestration layer**, BooCode needs five capabilities that compound on each other:
1. **Observability** — Every tool call timed, logged, and live-streamed. Without it, debugging agent behavior is guesswork.
2. **Persistence** — Agent state survives browser refresh. Active sessions resume where they left off.
3. **Dynamic Workflows** — User-authored JS scripts using `agent()`, `parallel()`, `pipeline()` instead of hardcoded flows. Hash-based caching skips completed steps on re-run.
4. **Background Subagents**`spawn_subagent` returns immediately, results collected later. Unlocks parallel research, long-running analyses, and notification-based workflows.
5. **Multi-modal + Cache Shape** — Image attachments forwarded to DeepSeek's vision API, plus per-turn cache hit rate visualization to close the cost feedback loop.
Each phase is independently valuable; together they transform BooCode from a chat UI into a durable agent execution platform.
## What Changes
### Phase 1: Trace System + Observability (3-4 days)
1. **Create `tool_traces` DB table** — id, session_id, chat_id, turn_number, tool_name, input, output, started_at, finished_at, latency_ms, tokens_used, cache_tokens, reasoning_tokens, error, outcome. Applied idempotently via `applySchema()`.
2. **Add `tool_trace` WS frame** — new WsFrame variant in `@boocode/contracts` published by the server when a tool call starts and completes. Frontend receives live timing deltas via `useSessionStream`.
3. **Instrument `tool-phase.ts`** — wrap `executeToolCall` with `clock_timestamp()` start/end, extract token counts from LLM response metadata, publish `tool_trace` frames on start (with input) and finish (with output + metrics).
4. **Add GET `/api/chats/:id/traces`** — paginated endpoint returning trace rows ordered by turn_number + started_at. Supports cursor-based pagination for large sessions.
5. **Build trace viewer pane** — collapsible tree per turn, timing bars showing latency relative to turn duration, expand/collapse per tool call showing input/output. Integrates into the existing multi-pane workspace alongside chat, coder, and orchestrator panes.
### Phase 2: Session Persistence + Resume (2-3 days)
6. **Serialize agent state to DB** — on each turn boundary (before and after tool call loop), snapshot the active `AgentSession` state (provider config, turn history, pending tool calls) to a JSONB column in `agent_sessions`. Uses `clock_timestamp()` for ordering.
7. **Restore on WS reconnect** — when `snapshot` frame arrives on reconnection, check for a persisted `AgentSession` in `in_progress` or `awaiting_input` state. Rehydrate the coder pane to match the persisted turn, tool call, and pending state.
8. **Agent session timeline view** — a timeline component in the coder pane showing the history of all turns in the current agent session. Each turn shows start time, tool count, token usage, cache hit rate. Clicking a turn scrolls to that point in the conversation.
### Phase 3: Dynamic Workflow Engine (5-7 days)
9. **Create `isolated-vm` sandbox** — restricted JS execution environment for workflow scripts. No `require`, `fs`, `net`, `child_process`. Only the workflow API surface exposed. Token budget enforcement kills runaway scripts.
10. **Implement workflow API primitives**`agent(id, { prompt, model, tools, budget })` defines a sub-agent; `parallel([agent1, agent2])` runs N agents concurrently with a shared token budget; `pipeline([step1, step2])` chains agents sequentially; `phase(name, { agents, budget })` groups agents under a named phase; `budget(limit)` sets token or step limits; `log(msg)` emits structured workflow log. Compatible with Claude Code workflow script format.
11. **Workflow file discovery** — scan `.boocode/workflows/*.js` (project-local), `~/.boocode/workflows/*.js` (global), and a built-in catalog directory. Each file exports a `workflow` object with `{name, description, run}`. Discovery runs on server start and on file change (optional watch mode).
12. **Workflow manager + built-in catalog**`WorkflowManager` class with `list()`, `get(name)`, `run(workflow, args)`, `cancel(runId)`, `status(runId)`. Concurrency limits (configurable max concurrent runs), token budgets per run. Built-in catalog includes: `deep-research` (parallel source search → per-source analysis → synthesis), `multi-review` (code health + security + standards reviews in parallel), `plan-verify` (generate plan → verify plan → generate tasks), `bounty-hunt` (parallel vulnerability scanning with different focuses).
13. **Workflow resumability** — SHA-256 hash of each agent spec (prompt + options). Before executing an agent, check if a completed result exists with the same hash. Skip cached agents, only execute new/changed ones. In-memory LRU cache for current session, optional DB persistence for cross-session reuse.
14. **Workflow UI integration** — extend the existing Orchestrator panel (used for Han flows) to support dynamic workflows. Workflow selector dropdown, live run pane with step-by-step progress, cancel button, log output stream, per-agent timing. Reuses the same run-pane component pattern.
### Phase 4: Background Subagents (2-3 days)
15. **Background task queue** — uses the existing `tasks` table with a new `background` type. `spawn_subagent` tool creates a task row and returns immediately. A background worker picks up the task and executes it without blocking the calling agent.
16. **`subagent_status` + `subagent_result` tools** — `subagent_status(task_id)` returns `running|completed|failed` with optional progress info. `subagent_result(task_id)` returns the full output when completed. Polling-based (no WS push for background tasks initially).
17. **Background agent pane** — new pane type showing running/completed background agents. Each entry shows name, status, duration, progress. Completed entries show a "View Result" action. Notifications hook into the existing notification system (toast on completion, badge count for active tasks).
### Phase 5: Multi-modal + Cache Shape (2-3 days)
18. **Image/file attachment pipeline** — accept file uploads (drag-drop or file picker), store on tmpfs with a reference in the message row. Forward to DeepSeek's multimodal API as base64-encoded image parts. Size limit enforcement (configurable, default 20MB per attachment).
19. **Image render in message bubble** — render attached images inline in the chat message bubble. Lightbox on click for expanded view. Thumbnail generation for large images to keep chat scrolling performant.
20. **Cache shape telemetry** — extract `prompt_cache_hit_tokens` from DeepSeek provider metadata on each turn. Break down by segment: system prompt, tool schemas, conversation history. Store in `tool_traces` columns and/or a dedicated `cache_stats` table.
21. **Cache hit rate visualization** — per-turn cache hit bar in the trace viewer (showing cached vs non-cached tokens). Cumulative cache hit rate in the session footer. Highlight when a turn achieves high cache reuse (green indicator) or unusually low (yellow/red).
## Non-Goals
- No changes to the existing Han flow orchestrator (runs alongside dynamic workflows)
- No removal of existing agent dispatch paths (PTY, ACP, Claude SDK — dynamic workflows are additive)
- No distributed execution (all orchestration is single-node)
- No persistent workflow file watching (manual reload or server restart to pick up new workflows)
- No workflow editing UI (workflows are authored as JS files)
## Capabilities
### New Capabilities
- **Tool trace viewer** — every tool call with timing, token costs, cache breakdown, expandable input/output
- **Agent session resume** — browser refresh preserves active agent state
- **Dynamic workflows** — user-authored JS scripts with `agent()/parallel()/pipeline()` API
- **Workflow resumability** — hash-based step caching skips completed agents on re-run
- **Built-in workflow catalog** — deep-research, multi-review, plan-verify, bounty-hunt
- **Background subagents** — non-blocking spawn with deferred result collection
- **Multi-modal support** — image attachments forwarded to DeepSeek vision API
- **Cache shape telemetry** — per-turn and cumulative cache hit rate visualization
### Modified Capabilities
- **Orchestrator panel** — extended from fixed Han flows to dynamic workflow selection and streaming run pane
- **tool-phase.ts** — instrumented with start/end timing and trace publishing
- **WsFrame contract** — new `tool_trace` frame variant
- **tasks table** — extended with `background` type for async subagent execution
## Metrics
- Tool call observability: 0% → 100% of calls traced with timing
- Session continuity: lost on refresh → preserved on reconnect
- Workflow authoring: hardcoded → user-authored JS scripts
- Workflow re-run efficiency: 0% cache → hash-based step reuse
- Background execution: blocking only → blocking + non-blocking
- Cache visibility: 0% → per-turn + cumulative hit rate
- Multi-modal: text-only → text + image attachments

View File

@@ -0,0 +1,230 @@
# Tasks — Paseo-like Orchestrator
## Phase 1: Trace System + Observability (5 tasks)
### 1. Create tool_traces DB table + migration
Add `tool_traces` table to `apps/server/src/schema.sql`:
- Columns: id (UUID PK), session_id (UUID FK → sessions), chat_id (UUID FK → chats), turn_number (int), tool_name (text), input (jsonb), output (jsonb), started_at (timestamptz), finished_at (timestamptz), latency_ms (int), tokens_used (int), cache_tokens (int), reasoning_tokens (int), error (text), outcome (text)
- Index on (chat_id, turn_number, started_at) for trace queries
- Index on (session_id) for session-level aggregation
- Applied idempotently via `applySchema()` — wrap in `CREATE TABLE IF NOT EXISTS`
**Verification**: `psql` shows `tool_traces` table with all columns and indexes. Schema re-run is no-op.
### 2. Add tool_trace WS frame + contracts schema
Add `tool_trace` frame to `WsFrameSchema` in `packages/contracts/src/ws-frames.ts`:
- Frame types: `tool_trace:start` (tool_name, input, started_at) and `tool_trace:complete` (tool_name, output, latency_ms, tokens_used, cache_tokens, reasoning_tokens, error)
- Add to `InferenceFrame` loose union in `apps/server/src/services/inference/turn.ts`
- Add to strict `WsFrame` discriminated union in `apps/web/src/api/types.ts`
- Rebuild contracts: `pnpm -C packages/contracts build`
**Verification**: tsc --noEmit passes. WS client receives `tool_trace:start` and `tool_trace:complete` frames.
### 3. Instrument tool-phase.ts with start/end timing
Update `apps/server/src/services/tools/tool-phase.ts`:
- Before `executeToolCall`: record `clock_timestamp()` as start, publish `tool_trace:start` frame with tool_name and input
- After `executeToolCall`: record `clock_timestamp()` as finish, compute latency_ms, extract token counts from response metadata, INSERT into `tool_traces` table, publish `tool_trace:complete` frame
- Handle errors: on thrown error, publish `tool_trace:complete` with error field set, set outcome='error'; on success, outcome='success'
- Use `sql.json(input as never)` for JSONB columns — no double-serialization
**Verification**: Every tool call produces a `tool_traces` row with correct latency_ms and outcome. WS client receives both start and complete frames.
### 4. Add GET /api/chats/:id/traces endpoint
Create `apps/server/src/routes/traces.ts`:
- `GET /api/chats/:id/traces` — paginated, ordered by (turn_number, started_at)
- Query params: `cursor` (opaque cursor for keyset pagination), `limit` (default 50, max 200), `turn_number` (optional filter to single turn)
- Returns `{traces: Trace[], next_cursor: string | null}`
- Register in Fastify router with `chatOwnershipPreHandler` guard
**Verification**: `curl /api/chats/:id/traces` returns paginated trace rows. Turn filter returns only matching traces.
### 5. Build trace viewer frontend component
Create `apps/web/src/components/TraceViewer.tsx` (and supporting files):
- Collapsible tree grouped by turn_number
- Per tool call row: tool_name badge, latency bar (relative bar width, color-coded: green <1s, yellow <5s, red ≥5s), token count, expand/collapse chevron
- Expanded view: tool input (JSON formatted), tool output (JSON formatted), error message if any
- Fetch traces from `/api/chats/:id/traces` on pane mount, paginate on scroll
- Integrate as a new pane option in the multi-pane workspace (existing pane registry)
**Verification**: Trace viewer loads, groups by turn, shows timing bars, expands/collapses tool calls. Pagination works for sessions with 50+ traces.
## Phase 2: Session Persistence + Resume (3 tasks)
### 6. Serialize agent state to DB on turn boundaries
Modify `apps/coder` agent dispatch:
- On each turn boundary (after LLM response, before next tool call loop), serialize `AgentSession` state to `agent_sessions` table
- Persist: provider config, turn history, pending tool calls, current phase, token budget remaining
- Use JSONB column for the snapshot state, `clock_timestamp()` for last_update
- Guard against rapid consecutive saves (debounce 200ms)
**Verification**: Agent session state is written to `agent_sessions` after each LLM turn. JSONB snapshot contains all fields needed for resume.
### 7. Restore state on WS reconnect
Update `apps/server/src/services/ws.ts`:
- On `snapshot` frame from a reconnecting client, check for `AgentSession` in `in_progress` or `awaiting_input` state
- If found, rehydrate the coder pane: restore provider config, replay pending tool calls, set turn history
- Publish a `session_restored` frame with the restored state metadata
- Client-side: `useSessionStream` handles `session_restored` by resetting pane state to match
**Verification**: Refresh browser mid-agent-session → after reconnect, the coder pane shows the same turn state, pending tool calls, and conversation history.
### 8. Agent session timeline view
Add timeline component to the coder pane:
- Horizontal timeline showing all turns in the current agent session
- Each turn entry: turn number, start time, tool call count, token usage, cache hit rate
- Active turn highlighted, past turns dimmed
- Clicking a past turn scrolls the conversation to that turn and collapses later turns
- Fetch turn metadata from existing session data (no new endpoint needed)
**Verification**: Timeline shows all turns. Clicking a turn scrolls to it. Active turn is highlighted.
## Phase 3: Dynamic Workflow Engine (6 tasks)
### 9. Create isolated-vm workflow sandbox
Create `apps/server/src/services/workflow/sandbox.ts`:
- Use `isolated-vm` npm package to create a V8 isolate for each workflow run
- No `require`, `fs`, `net`, `child_process` accessible in the sandbox
- Expose only the workflow API surface (`agent`, `parallel`, `pipeline`, `phase`, `budget`, `log`, `args`)
- Token budget enforcement: inject a step counter, throw when budget exceeded
- Timeout: 30s default, configurable per workflow
- Error boundary: caught exceptions produce structured error results instead of crashing the worker
- Add `isolated-vm` to `apps/server/package.json` dependencies
**Verification**: Workflow script that calls `agent()` runs without error. Script trying `require('fs')` throws a sandbox violation. Run exceeding budget is killed with a clear message.
### 10. Implement agent/parallel/pipeline primitives
Create `apps/server/src/services/workflow/api.ts`:
- `agent(id, { prompt, model?, tools?, budget? })` — registers a sub-agent. Returns an object with `.run(input)` that dispatches the agent through the existing agent dispatch system and returns result.
- `parallel([agents], { budget? })` — runs all agents concurrently. Returns when all complete (or any fails). Shared token budget across parallel agents. Uses `Promise.allSettled` for resilience.
- `pipeline([steps], { budget? })` — runs steps sequentially. Each step receives the previous step's output. Steps can be `agent()` results or inline functions.
- `phase(name, { agents, budget })` — groups agents under a named phase. Phases can have their own budget. Results are namespaced by phase name.
- `budget(limit)` — sets token or step limits. Returns a budget object consumed by agent/parallel/pipeline.
- `log(msg)` — emits a structured log entry tagged with current phase/agent context. Published as WS frame to the Orchestrator pane.
- `args` — the input arguments passed to `workflow.run(args)`.
**Verification**: A test workflow using `agent()`, `parallel()`, and `pipeline()` executes correctly. Logs appear in the output stream. Token budgets are enforced.
### 11. Workflow file discovery system
Create `apps/server/src/services/workflow/discovery.ts`:
- Scan `.boocode/workflows/*.js` (project root, relative to `PROJECT_ROOT_WHITELIST`)
- Scan `~/.boocode/workflows/*.js` (global, `os.homedir()`)
- Scan `data/workflows/` (built-in catalog)
- Each file must export a `workflow` object: `{name, description, run(args) => {...}}`
- Validate the workflow object at discovery time: required fields, run must be a function
- On server start, run full discovery. Cache results in a `Map<name, Workflow>`.
- Log discovered workflows with name + description at `info` level
**Verification**: Placing a valid `.boocode/workflows/test.js` file makes the workflow appear in `WorkflowManager.list()`. Invalid workflow files are logged as warnings and skipped.
### 12. Workflow manager + built-in catalog
Create `apps/server/src/services/workflow/manager.ts`:
- `WorkflowManager` singleton class:
- `list()` — returns all discovered workflows with name, description, and arg schema
- `get(name)` — returns a workflow by name
- `run(workflow, args)` — creates a sandbox, injects args, executes `workflow.run()`. Returns a runId (UUID).
- `cancel(runId)` — terminates the sandbox, marks run as cancelled
- `status(runId)` — returns run status: `pending|running|completed|failed|cancelled`, with progress info
- Concurrency limit: configurable via `WORKFLOW_MAX_CONCURRENT` env var (default 3)
- Token budget: configurable via `WORKFLOW_DEFAULT_BUDGET` env var (default 100_000 tokens)
- Run state tracked in-memory with optional DB persistence
Built-in workflows in `data/workflows/`:
- `deep-research` — parallel source search → per-source analysis → synthesis report
- `multi-review` — run code health + security + standards reviews in parallel, merge findings
- `plan-verify` — generate implementation plan → verify plan → generate work items
- `bounty-hunt` — parallel vulnerability scans with different focus areas (injection, auth, crypto, business logic)
**Verification**: `list()` returns built-in workflows. `run()` executes a workflow and returns runId. `status()` reflects progress. `cancel()` stops execution cleanly.
### 13. Workflow resumability (hash-based cache)
Create `apps/server/src/services/workflow/cache.ts`:
- Compute SHA-256 hash of each agent spec: `crypto.createHash('sha256').update(JSON.stringify({prompt, options})).digest('hex')`
- Before executing an agent, check in-memory LRU cache for existing result matching the hash
- Hit: return cached result, emit `log('cached', agentId, hash)` — no actual dispatch
- Miss: execute agent, store result in cache keyed by hash
- LRU eviction: `WORKFLOW_CACHE_SIZE` env var (default 100 entries)
- Optional DB persistence: `workflow_cache` table with `hash`, `result`, `created_at` — cross-session reuse
- Re-run detection: identical workflow with same args → all agents skipped
- Partial re-run: changed args → only changed agents re-execute, unchanged ones read from cache
**Verification**: First run of a workflow executes all agents. Second run with identical args skips all agents (logs show 'cached'). Run with modified args for one agent only re-executes that agent.
### 14. Workflow UI integration with Orchestrator panel
Extend `apps/web/src/components/Orchestrator/`:
- Add workflow selector dropdown listing workflows from `WorkflowManager.list()`
- Add "Run Workflow" button that opens workflow args editor (JSON or form)
- Extend existing run pane to show workflow steps with per-agent progress
- Live log stream from workflow `log()` calls, displayed in a scrollable log view
- Cancel button for running workflows
- Resumability indicator: "3/5 steps cached — skipping" when hash cache hits
- Fetch workflow list via new API endpoint or WS message (add `GET /api/orchestrator/workflows`)
**Verification**: Workflow selector lists built-in workflows. Running a workflow shows step-by-step progress in the run pane. Cancelling a running workflow works. Cached steps show "skipped" indicator.
## Phase 4: Background Subagents (3 tasks)
### 15. Background task queue + spawn_subagent tool
Modify `apps/coder/` and `apps/server/`:
- Extend `tasks` table usage with a new task type marker for background subagent tasks
- Create `spawn_subagent` tool in `apps/server/src/services/tools/`:
- Schema: `{prompt, model?, tools?, budget?, metadata?}`
- Creates a `tasks` row with state=`pending`, type=`background_subagent`
- Returns `{task_id, status: 'pending'}` immediately — does NOT block
- Background worker loop: polls `tasks` table for `background_subagent` tasks in `pending` state, picks one up, executes it via existing agent dispatch, writes result back to tasks row on completion
- Max concurrency: `BACKGROUND_MAX_CONCURRENT` env var (default 2)
- Worker polls interval: 1s (configurable)
**Verification**: Calling `spawn_subagent` returns immediately with a task_id. The task eventually completes with a result in the tasks table. Multiple background tasks run concurrently up to the concurrency limit.
### 16. subagent_status + subagent_result tools
Create two tools in `apps/server/src/services/tools/`:
- `subagent_status(task_id)`:
- Schema: `{task_id}`
- Returns: `{task_id, status: 'pending'|'running'|'completed'|'failed', progress?: string, started_at?, finished_at?}`
- Queries `tasks` table for the status
- `subagent_result(task_id)`:
- Schema: `{task_id}`
- Returns: `{task_id, status, result?: json, error?: string}`
- Only returns result when status='completed'; returns empty result otherwise with a message
- Updates task state to `read` on successful result retrieval (optional)
**Verification**: Calling `subagent_status` on a running task returns 'running'. Calling `subagent_result` on a completed task returns the full result. Calling `subagent_result` on a pending task returns a clear "not ready yet" message.
### 17. Background agent pane
Create `apps/web/src/components/BackgroundAgentPane.tsx`:
- New pane type showing running, completed, and failed background subagents
- Each entry: agent name/description, status badge, duration (elapsed or total), progress indicator
- Running entries: progress bar (if available), cancel button
- Completed entries: "View Result" action that opens a modal or inline view with the full output
- Failed entries: error message, "Retry" action
- Badge counter on pane tab showing number of running tasks
- Poll status every 2s for running entries, stop polling on completion
- Register in pane registry alongside existing pane types
**Verification**: Background pane shows spawning tasks as "pending", transitioning to "running", then "completed"/"failed". "View Result" shows the full output. Badge counter reflects active running tasks.
## Phase 5: Multi-modal + Cache Shape (4 tasks)
### 18. Multi-modal attachment pipeline
Add file upload support:
- Accept file uploads via drag-drop or file picker in the message input area
- Store uploaded files on tmpfs (`/tmp/boocode-uploads/` by default, configurable via `UPLOAD_DIR`)
- Reference attachments in message row via `message_parts` with `type='image'` and a `url` pointing to the tmpfs path
- Forward to DeepSeek API: encode image as base64 data URI, send as multimodal content part in the user message
- Supported formats: png, jpg, jpeg, gif, webp
- Size limit: 20MB default, configurable via `MAX_ATTACHMENT_SIZE_MB` env var
- Server-side cleanup: delete tmpfs files after message is fully processed or on a periodic sweep
**Verification**: Uploading an image creates a file on tmpfs and a referenced `message_parts` row. DeepSeek API call includes the image as a base64 content part. Error on files over size limit.
### 19. Image render in message bubble
Update message rendering in `apps/web/src/components/MessageBubble.tsx`:
- Detect `message_parts` with `type='image'` in the message content
- Render attached images inline in the chat bubble, below the text content
- Thumbnail: max 300px wide, aspect-ratio preserved, rounded corners
- Lightbox: clicking the thumbnail opens a full-size overlay with close button
- Loading state: skeleton placeholder while image loads from tmpfs URL
- Error state: broken image placeholder with retry option
- Clean layout: images displayed in a grid (1-2 columns depending on count)
**Verification**: Chat messages with image attachments render inline thumbnails. Clicking opens lightbox. Large images are thumbnailed. Broken images show error state.
### 20. Cache shape telemetry data pipeline
Extract and store cache metrics:
- In the DeepSeek provider response handler, extract `prompt_cache_hit_tokens` and `prompt_cache_miss_tokens` from the API response metadata
- Break down cache segments: system prompt tokens, tool schema tokens, conversation history tokens (approximate by measuring each segment length)
- Store cache metrics in `tool_traces.cache_tokens` column (already created in Phase 1)
- Optionally create a `cache_stats` table for per-segment breakdown: `{turn_id, segment_name, hit_tokens, miss_tokens}`
- Expose via existing traces API (cache fields already part of the Trace schema)
**Verification**: After a DeepSeek call, `tool_traces` row has `cache_tokens` populated. Cache segment breakdown is available when querying traces.
### 21. Cache shape visualization in trace viewer
Update the TraceViewer component with cache metrics:
- Per-turn cache hit bar: horizontal stacked bar showing cached (green) vs non-cached (gray) tokens
- Hit rate percentage displayed as a badge next to token count
- Cumulative cache hit rate in the session footer: "Cache hit rate: 67% (45K/67K tokens)"
- Color coding: green ≥60%, yellow 30-59%, red <30%
- Tooltip on hover showing segment breakdown if available
- Animate transitions when new trace data arrives
**Verification**: Trace viewer shows cache hit/miss bars per turn. Cumulative rate in footer updates as new traces load. Color coding matches thresholds.

View File

@@ -355,7 +355,7 @@ export const FlowRunStepUpdatedFrame = z.object({
type: z.literal('flow_run_step_updated'), type: z.literal('flow_run_step_updated'),
run_id: Uuid, run_id: Uuid,
step_id: z.string().min(1), step_id: z.string().min(1),
status: z.enum(['pending', 'running', 'completed', 'failed', 'skipped', 'cancelled']), status: z.enum(['pending', 'running', 'completed', 'failed', 'skipped', 'cancelled', 'timed_out']),
run_status: z.enum(['running', 'completed', 'failed', 'cancelled']).optional(), run_status: z.enum(['running', 'completed', 'failed', 'cancelled']).optional(),
report: z.string().optional(), report: z.string().optional(),
}); });
@@ -407,11 +407,141 @@ export const BattleUpdatedFrame = z.object({
cross_exam_id: Uuid.optional(), cross_exam_id: Uuid.optional(),
}); });
// ---- agent snapshot restore frame ------------------------------------------
export const AgentSnapshotFrame = z.object({
type: z.literal('agent_snapshot'),
chat_id: z.string().uuid(),
agent: z.string().nullable().optional(),
model: z.string(),
mode: z.string().nullable().optional(),
turn_number: z.number().int().nonnegative(),
});
// ---- tool trace frames -----------------------------------------------------
export const ToolTraceStartFrame = z.object({
type: z.literal('tool_trace_start'),
trace_id: z.string().uuid(),
message_id: z.string().uuid(),
chat_id: z.string().uuid(),
tool_name: z.string().min(1),
tool_input: z.record(z.unknown()),
started_at: z.string().datetime(),
});
export const ToolTraceFinishFrame = z.object({
type: z.literal('tool_trace_finish'),
trace_id: z.string().uuid(),
message_id: z.string().uuid(),
chat_id: z.string().uuid(),
tool_name: z.string().min(1),
tool_output: z.union([z.string(), z.null()]).optional(),
latency_ms: z.number().int().nonnegative().optional(),
tokens_used: z.number().int().nonnegative().nullable().optional(),
cache_tokens: z.number().int().nonnegative().nullable().optional(),
reasoning_tokens: z.number().int().nonnegative().nullable().optional(),
error: z.string().optional(),
outcome: z.string().optional(),
finished_at: z.string().datetime(),
});
// ---- collision warning frame (v2.8) ----------------------------------------
//
// Published when the BooCoder detects that multiple worktrees/agents are editing
// the same file concurrently. Advisory only — writes are not blocked.
const ConflictSeverityValue = z.enum(['same_line', 'adjacent_line', 'different_area']);
export const CollisionWarningFrame = z.object({
type: z.literal('collision_warning'),
file_path: z.string().min(1),
worktrees: z.array(z.string().min(1)),
agents: z.array(z.string().min(1)),
severity: ConflictSeverityValue,
});
// ---- channel-delta frames (streaming v2) ----------------------------------
//
// Each channel frame carries a monotonic `seq` counter so the client can
// reorder out-of-order deltas per-channel, detect gaps, and request replay on
// reconnect. The `channel` discriminator tells the reducer which substate to
// update.
const TextChannelPayload = z.object({
message_id: Uuid,
chat_id: Uuid.optional(),
content: z.string(),
});
const ToolCallChannelPayload = z.object({
message_id: Uuid,
chat_id: Uuid.optional(),
tool_call: ToolCallShape,
});
const ToolResultChannelPayload = z.object({
tool_message_id: Uuid,
chat_id: Uuid.optional(),
tool_call_id: ToolCallId,
output: z.unknown(),
truncated: z.boolean(),
error: z.string().optional(),
});
const StatusChannelPayload = z.object({
message_id: Uuid,
chat_id: Uuid.optional(),
status: z.enum(['running', 'complete', 'cancelled', 'failed']).optional(),
tokens_used: z.number().int().nonnegative().nullable().optional(),
ctx_used: z.number().int().nonnegative().nullable().optional(),
ctx_max: z.number().int().positive().nullable().optional(),
cache_tokens: z.number().int().nonnegative().nullable().optional(),
reasoning_tokens: z.number().int().nonnegative().nullable().optional(),
started_at: IsoTimestamp.nullable().optional(),
finished_at: IsoTimestamp.nullable().optional(),
model: z.string().nullable().optional(),
metadata: OpaqueObject.nullable().optional(),
});
const ErrorChannelPayload = z.object({
message_id: Uuid.optional(),
chat_id: Uuid.optional(),
error: z.string(),
reason: ErrorReasonValue.optional(),
});
const ChannelDeltaPayload = z.discriminatedUnion('channel', [
z.object({ channel: z.literal('text'), ...TextChannelPayload.shape }),
z.object({ channel: z.literal('tool_call'), ...ToolCallChannelPayload.shape }),
z.object({ channel: z.literal('tool_result'), ...ToolResultChannelPayload.shape }),
z.object({ channel: z.literal('status'), ...StatusChannelPayload.shape }),
z.object({ channel: z.literal('error'), ...ErrorChannelPayload.shape }),
]);
export const ChannelDeltaFrame = z.object({
type: z.literal('channel_delta'),
seq: z.number().int().nonnegative(),
channel: z.union([
z.literal('text'), z.literal('tool_call'),
z.literal('tool_result'), z.literal('status'), z.literal('error'),
]),
message_id: Uuid.optional(),
chat_id: Uuid.optional(),
content: z.string().optional(),
tool_call: ToolCallShape.optional(),
tool_message_id: Uuid.optional(),
tool_call_id: ToolCallId.optional(),
output: z.unknown().optional(),
truncated: z.boolean().optional(),
});
// ---- discriminated union --------------------------------------------------- // ---- discriminated union ---------------------------------------------------
export const WsFrameSchema = z.discriminatedUnion('type', [ export const WsFrameSchema = z.discriminatedUnion('type', [
// per-session // per-session
SnapshotFrame, SnapshotFrame,
AgentSnapshotFrame,
MessageStartedFrame, MessageStartedFrame,
DeltaFrame, DeltaFrame,
ReasoningDeltaFrame, ReasoningDeltaFrame,
@@ -434,6 +564,13 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
BattleStartedFrame, BattleStartedFrame,
ContestantUpdatedFrame, ContestantUpdatedFrame,
BattleUpdatedFrame, BattleUpdatedFrame,
// tool trace
ToolTraceStartFrame,
ToolTraceFinishFrame,
// collision warning
CollisionWarningFrame,
// channel-delta (streaming v2)
ChannelDeltaFrame,
// per-user // per-user
ChatStatusFrame, ChatStatusFrame,
SessionUpdatedFrame, SessionUpdatedFrame,
@@ -461,6 +598,7 @@ export type WsFrame = z.infer<typeof WsFrameSchema>;
// by the drift test in src/__tests__/ws-frames.test.ts. // by the drift test in src/__tests__/ws-frames.test.ts.
export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'snapshot', 'snapshot',
'agent_snapshot',
'message_started', 'message_started',
'delta', 'delta',
'reasoning_delta', 'reasoning_delta',
@@ -481,6 +619,10 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'battle_started', 'battle_started',
'contestant_updated', 'contestant_updated',
'battle_updated', 'battle_updated',
'tool_trace_start',
'tool_trace_finish',
'collision_warning',
'channel_delta',
'chat_status', 'chat_status',
'session_updated', 'session_updated',
'session_renamed', 'session_renamed',

15
pnpm-lock.yaml generated
View File

@@ -97,6 +97,9 @@ importers:
apps/server: apps/server:
dependencies: dependencies:
'@ai-sdk/deepseek':
specifier: ^2.0.35
version: 2.0.35(zod@3.25.76)
'@ai-sdk/openai-compatible': '@ai-sdk/openai-compatible':
specifier: ^2.0.47 specifier: ^2.0.47
version: 2.0.47(zod@3.25.76) version: 2.0.47(zod@3.25.76)
@@ -302,6 +305,12 @@ packages:
peerDependencies: peerDependencies:
zod: ^3.25.0 || ^4.0.0 zod: ^3.25.0 || ^4.0.0
'@ai-sdk/deepseek@2.0.35':
resolution: {integrity: sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@3.0.119': '@ai-sdk/gateway@3.0.119':
resolution: {integrity: sha512-VAhfRWC+JexZakkVfmjaJKaTj00x7/UHdE8kMWL3NhuQAlf8oXtg9r4dfvFZrByXxchGRBvYE3biEUyibkg0xg==} resolution: {integrity: sha512-VAhfRWC+JexZakkVfmjaJKaTj00x7/UHdE8kMWL3NhuQAlf8oXtg9r4dfvFZrByXxchGRBvYE3biEUyibkg0xg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -4363,6 +4372,12 @@ snapshots:
dependencies: dependencies:
zod: 3.25.76 zod: 3.25.76
'@ai-sdk/deepseek@2.0.35(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/gateway@3.0.119(zod@3.25.76)': '@ai-sdk/gateway@3.0.119(zod@3.25.76)':
dependencies: dependencies:
'@ai-sdk/provider': 3.0.10 '@ai-sdk/provider': 3.0.10