fix: address all review comments, lint/format, bugbot issues
Review fixes: - metadata: sessionId validation to prevent path traversal - event-bus: guard appendFileSync, remove unused import - lifecycle-manager: fix reaction retry counting (track attempts, clear on status transition), allow retry on send failure instead of immediate escalation, detect killed sessions in polling loop, map session.killed to agent-exited reaction - session-manager: validate status against union, clean up runtime and workspace on spawn failure, kill with default plugins when project config is missing Lint/format fixes: - Remove unused imports across all test files - Replace `Function` type with typed callback in tmux tests - Replace require() with dynamic import() in tests - Replace dynamic delete with object spread in metadata - Apply prettier formatting to all files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c49649c2c1
commit
2ecb011982
|
|
@ -23,7 +23,7 @@ An open-source, agent-agnostic system for orchestrating parallel AI coding agent
|
|||
8 plugin slots — every abstraction is swappable:
|
||||
|
||||
| Slot | Interface | Default Plugin |
|
||||
|------|-----------|---------------|
|
||||
| --------- | --------------------- | -------------- |
|
||||
| Runtime | `Runtime` | tmux |
|
||||
| Agent | `Agent` | claude-code |
|
||||
| Workspace | `Workspace` | worktree |
|
||||
|
|
@ -170,6 +170,7 @@ pnpm test # run all tests
|
|||
### Before Committing
|
||||
|
||||
Always run lint and typecheck:
|
||||
|
||||
```bash
|
||||
pnpm lint && pnpm typecheck
|
||||
```
|
||||
|
|
@ -188,7 +189,7 @@ Fix any issues before pushing. CI will reject PRs that fail lint or typecheck.
|
|||
The `scripts/` directory contains the original bash scripts that this TypeScript codebase replaces. Use them as behavioral specifications:
|
||||
|
||||
| Script | What It Specifies |
|
||||
|--------|------------------|
|
||||
| ------------------------- | ------------------------------------------------------ |
|
||||
| `claude-ao-session` | Session lifecycle (spawn, list, kill, cleanup) |
|
||||
| `claude-dashboard` | Web dashboard, API, activity detection |
|
||||
| `claude-batch-spawn` | Batch spawning with duplicate detection |
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ You are the **orchestrator agent** for the agent-orchestrator project. You manag
|
|||
## Commands Reference
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| ---------------------- | ------------------------------------------------------------ |
|
||||
| **See all sessions** | `~/claude-status` |
|
||||
| **Batch spawn** | `~/claude-batch-spawn ao AO-1 AO-2 AO-3` |
|
||||
| **Single spawn** | `~/claude-spawn ao AO-1` |
|
||||
|
|
@ -67,6 +67,7 @@ You are the **orchestrator agent** for the agent-orchestrator project. You manag
|
|||
## Typical Workflows
|
||||
|
||||
### Spawn Work for Linear Tickets
|
||||
|
||||
```bash
|
||||
# 1. Check what's already running
|
||||
~/claude-status
|
||||
|
|
@ -79,6 +80,7 @@ You are the **orchestrator agent** for the agent-orchestrator project. You manag
|
|||
```
|
||||
|
||||
### Check Progress
|
||||
|
||||
```bash
|
||||
~/claude-status # Quick overview
|
||||
~/claude-ao-session ls # AO sessions only
|
||||
|
|
@ -86,6 +88,7 @@ tmux capture-pane -t "ao-1" -p -S -30 # Peek at session
|
|||
```
|
||||
|
||||
### Ask a Session to Do Something
|
||||
|
||||
```bash
|
||||
# Short message
|
||||
~/send-to-session ao-1 "address the unresolved comments on your PR"
|
||||
|
|
@ -98,6 +101,7 @@ PROMPT
|
|||
```
|
||||
|
||||
### Cleanup
|
||||
|
||||
```bash
|
||||
~/claude-ao-session cleanup # Kills sessions with merged PRs / completed tickets
|
||||
~/claude-ao-session kill ao-3 # Kill specific session
|
||||
|
|
@ -106,7 +110,9 @@ PROMPT
|
|||
## Session Data
|
||||
|
||||
### Metadata Files
|
||||
|
||||
Each session has a flat file at `~/.ao-sessions/ao-N`:
|
||||
|
||||
```
|
||||
worktree=/Users/equinox/.worktrees/ao/ao-1
|
||||
branch=feat/AO-1
|
||||
|
|
@ -116,6 +122,7 @@ pr=https://github.com/ComposioHQ/agent-orchestrator/pull/5
|
|||
```
|
||||
|
||||
### Environment Variables (inside sessions)
|
||||
|
||||
- `AO_SESSION` — e.g., `ao-1`
|
||||
- `LINEAR_API_KEY` — required for cleanup to check ticket status
|
||||
|
||||
|
|
@ -151,6 +158,7 @@ agent-orchestrator/
|
|||
## Architecture
|
||||
|
||||
### Session Lifecycle
|
||||
|
||||
```
|
||||
spawn → tmux session created → Claude started → working on ticket
|
||||
↓
|
||||
|
|
@ -164,12 +172,15 @@ PR merged → cleanup kills session, archives metadata
|
|||
```
|
||||
|
||||
### Activity Detection
|
||||
|
||||
The dashboard detects if agents are working/idle/exited by:
|
||||
|
||||
1. Checking Claude's JSONL session file modification time and last message type
|
||||
2. Walking the process tree from tmux pane PID to find `claude` processes
|
||||
3. Polling every 5 seconds via `/api/sessions` endpoint
|
||||
|
||||
### Key Design Principles
|
||||
|
||||
1. **tmux-based** — persistence, detach/attach, scriptability
|
||||
2. **Flat metadata files** — `key=value` format, easy to parse and update
|
||||
3. **Worktree isolation** — each session gets its own git worktree
|
||||
|
|
@ -195,6 +206,7 @@ The dashboard detects if agents are working/idle/exited by:
|
|||
## Linear Integration
|
||||
|
||||
Create tickets via Rube MCP:
|
||||
|
||||
```
|
||||
RUBE_SEARCH_TOOLS: queries=[{use_case: "create an issue in Linear"}]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Architecture Design — Agent Orchestrator
|
||||
|
||||
*Compiled: 2026-02-13*
|
||||
_Compiled: 2026-02-13_
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ Human only intervenes when notified. Everything else is handled.
|
|||
## Nomenclature
|
||||
|
||||
| Term | Definition | Examples |
|
||||
|------|-----------|---------|
|
||||
| ---------------- | ------------------------------------------ | -------------------------------- |
|
||||
| **Orchestrator** | The central server that manages everything | The Next.js app |
|
||||
| **Project** | A configured repository to work on | `my-app`, `backend-api` |
|
||||
| **Session** | A running agent instance working on a task | `my-app-1`, `my-app-2` |
|
||||
|
|
@ -125,7 +125,7 @@ interface Runtime {
|
|||
```
|
||||
|
||||
| Implementation | How it works | Best for |
|
||||
|---------------|-------------|----------|
|
||||
| ---------------- | ------------------------------ | ------------------------------ |
|
||||
| `tmux` (default) | tmux sessions + send-keys | Local development, interactive |
|
||||
| `process` | Child processes + stdin/stdout | Headless, CI/CD, scripting |
|
||||
| `docker` | Docker containers + exec | Isolation, reproducibility |
|
||||
|
|
@ -160,7 +160,7 @@ interface Agent {
|
|||
```
|
||||
|
||||
| Implementation | Launch command | Activity detection |
|
||||
|---------------|---------------|-------------------|
|
||||
| ----------------------- | --------------------------------------- | -------------------------- |
|
||||
| `claude-code` (default) | `claude --dangerously-skip-permissions` | JSONL mtime + process tree |
|
||||
| `claude-headless` | `claude -p --output-format stream-json` | stdout parsing |
|
||||
| `codex` | `codex` | Process detection |
|
||||
|
|
@ -184,7 +184,7 @@ interface Workspace {
|
|||
```
|
||||
|
||||
| Implementation | How | Tradeoff |
|
||||
|---------------|-----|----------|
|
||||
| -------------------- | ------------------------ | ---------------------------------------- |
|
||||
| `worktree` (default) | `git worktree add` | Fast, shared objects, requires same repo |
|
||||
| `clone` | `git clone` | Full isolation, slower, more disk |
|
||||
| `copy` | `cp -r` | No git dependency, heaviest |
|
||||
|
|
@ -210,7 +210,7 @@ interface Tracker {
|
|||
```
|
||||
|
||||
| Implementation | API | Auth |
|
||||
|---------------|-----|------|
|
||||
| ------------------ | ----------- | -------------- |
|
||||
| `github` (default) | `gh` CLI | GitHub token |
|
||||
| `linear` | GraphQL API | Linear API key |
|
||||
| `jira` | REST API | Jira token |
|
||||
|
|
@ -245,7 +245,7 @@ interface SCM {
|
|||
```
|
||||
|
||||
| Implementation | API | Features |
|
||||
|---------------|-----|----------|
|
||||
| ------------------ | ------------------- | -------------------------- |
|
||||
| `github` (default) | `gh` CLI + REST API | Full PR/CI/review support |
|
||||
| `gitlab` | REST API | MR/pipeline/review support |
|
||||
| `bitbucket` | REST API | PR/pipeline support |
|
||||
|
|
@ -277,7 +277,7 @@ interface NotifyAction {
|
|||
```
|
||||
|
||||
| Implementation | Channel | Best for | Actionable? |
|
||||
|---------------|---------|----------|-------------|
|
||||
| ------------------- | ---------------------------- | ------------------- | ----------------------------- |
|
||||
| `desktop` (default) | OS notifications (clickable) | Solo developer | Click → opens dashboard |
|
||||
| `slack` | Slack messages with buttons | Teams | Buttons → merge, review, kill |
|
||||
| `discord` | Discord messages | Communities | Links |
|
||||
|
|
@ -301,7 +301,7 @@ interface Terminal {
|
|||
```
|
||||
|
||||
| Implementation | How | Platform |
|
||||
|---------------|-----|----------|
|
||||
| ---------------- | ------------------------- | ------------- |
|
||||
| `auto` (default) | Detect best available | Any |
|
||||
| `iterm2` | AppleScript API | macOS |
|
||||
| `web` | xterm.js in browser | Any |
|
||||
|
|
@ -311,6 +311,7 @@ interface Terminal {
|
|||
### 8. Lifecycle Manager (Core — not pluggable)
|
||||
|
||||
The Lifecycle Manager is the orchestrator's brain. It:
|
||||
|
||||
- Polls SCM + Agent plugins on configurable intervals
|
||||
- Maintains state machine per session
|
||||
- Emits events on state transitions
|
||||
|
|
@ -397,7 +398,7 @@ The orchestrator operates on a simple principle: handle everything you can autom
|
|||
The orchestrator resolves these silently. The human is only notified if auto-resolution fails.
|
||||
|
||||
| Event | Auto-Response | Escalation |
|
||||
|-------|--------------|------------|
|
||||
| ---------------------- | -------------------------------- | -------------------------------- |
|
||||
| CI failed | Send fix prompt to agent | Notify after 2 failed attempts |
|
||||
| Review comments | Send "address comments" to agent | Notify if unresolved after 30min |
|
||||
| Bugbot/linter comments | Send fix prompt to agent | Notify if unresolved after 30min |
|
||||
|
|
@ -407,7 +408,7 @@ The orchestrator resolves these silently. The human is only notified if auto-res
|
|||
These always push a notification. The human's phone buzzes, Slack pings, etc.
|
||||
|
||||
| Event | Priority | Notification |
|
||||
|-------|----------|-------------|
|
||||
| -------------------------------------------------------------- | -------- | ----------------------------------------------------- |
|
||||
| **Agent needs input** (permission, question, stuck) | URGENT | "Session X needs your input" + deep link |
|
||||
| **Agent errored** (crashed, unrecoverable) | URGENT | "Session X crashed" + error context |
|
||||
| **PR ready to merge** (approved + CI green) | ACTION | "PR #42 ready to merge" + merge button |
|
||||
|
|
@ -543,6 +544,7 @@ projects:
|
|||
```
|
||||
|
||||
Everything else uses sensible defaults:
|
||||
|
||||
- Runtime: tmux
|
||||
- Agent: claude-code
|
||||
- Workspace: worktree
|
||||
|
|
@ -635,7 +637,7 @@ reactions:
|
|||
## Tech Stack
|
||||
|
||||
| Segment | Choice | Why |
|
||||
|---------|--------|-----|
|
||||
| ------------------- | --------------------------------------- | ---------------------------------------------------- |
|
||||
| **Core library** | TypeScript | Shared types across all packages |
|
||||
| **Web + API** | Next.js 15 (App Router) | SSR + API routes in one process |
|
||||
| **Styling** | Tailwind CSS | Dark theme, responsive |
|
||||
|
|
@ -744,6 +746,7 @@ agent-orchestrator/
|
|||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation (Dog-food ready)
|
||||
|
||||
- Monorepo scaffolding
|
||||
- Core types + interfaces
|
||||
- Config loader
|
||||
|
|
@ -758,6 +761,7 @@ agent-orchestrator/
|
|||
- Reaction engine (CI failed, changes requested, agent stuck)
|
||||
|
||||
### Phase 2: Multi-Runtime + More Plugins
|
||||
|
||||
- Process runtime (headless claude -p)
|
||||
- Docker runtime
|
||||
- Codex + Aider agent adapters
|
||||
|
|
@ -766,12 +770,14 @@ agent-orchestrator/
|
|||
- Web terminal (xterm.js)
|
||||
|
||||
### Phase 3: Cloud + Scale
|
||||
|
||||
- Kubernetes runtime
|
||||
- E2B / Fly.io runtimes
|
||||
- Cost tracking
|
||||
- Webhook-triggered spawning
|
||||
|
||||
### Phase 4: Team + Enterprise
|
||||
|
||||
- Dashboard auth
|
||||
- Role-based access
|
||||
- Remote session support
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Competitive Research — Agent Orchestration Tools
|
||||
|
||||
*Compiled: 2026-02-13*
|
||||
_Compiled: 2026-02-13_
|
||||
|
||||
## Overview
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
|
|||
**Architecture — MEOW Stack (Molecular Expression of Work):**
|
||||
|
||||
| Layer | What | How |
|
||||
|-------|------|-----|
|
||||
| ----------------------------- | ------------------------ | ------------------------------------------------------------------------------ |
|
||||
| **Beads** | Atomic work units | JSONL files tracked in Git. IDs like `gt-abc12`. Universal data/control plane. |
|
||||
| **Epics** | Hierarchical collections | Organize beads into tree structures for parallel/sequential execution |
|
||||
| **Molecules** | Workflow graphs | Sequenced beads with dependencies, gates, loops |
|
||||
|
|
@ -28,7 +28,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
|
|||
**Agent Roles (7 roles, 2 scopes):**
|
||||
|
||||
| Role | Scope | Purpose |
|
||||
|------|-------|---------|
|
||||
| ------------ | ----- | --------------------------------------------------------- |
|
||||
| **Mayor** | Town | Chief AI coordinator with full workspace context |
|
||||
| **Deacon** | Town | Health daemon running patrol loops |
|
||||
| **Dogs** | Town | Maintenance helpers |
|
||||
|
|
@ -38,6 +38,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
|
|||
| **Witness** | Rig | Supervises polecats, unblocks stuck work |
|
||||
|
||||
**Other Abstractions:**
|
||||
|
||||
- **Town** — Workspace directory (`~/gt/`) housing all projects
|
||||
- **Rigs** — Project containers wrapping git repositories
|
||||
- **Hooks** — Git worktree-based persistent storage surviving crashes
|
||||
|
|
@ -47,6 +48,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
|
|||
**Runtime Backends:** claude, gemini, codex, cursor, auggie, amp (per-rig config)
|
||||
|
||||
**Communication/Isolation:**
|
||||
|
||||
- Git worktrees for filesystem isolation per agent
|
||||
- Beads/Hooks for coordination (external state, not shared context windows)
|
||||
- GUPP: deterministic handoffs through version control, not LLM-judged phase gates
|
||||
|
|
@ -63,12 +65,14 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
|
|||
- **Closest to our current approach**
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Sessions**: Single-repo isolated branches via git worktrees + tmux sessions
|
||||
- **Workspaces**: Multi-repo synchronized development contexts
|
||||
- **Control Center**: Unified tmux session with windows for each context
|
||||
- **Labels**: Globally unique, human-readable names
|
||||
|
||||
**Features:**
|
||||
|
||||
- `par start my-feature` — creates worktree + branch + tmux session
|
||||
- `par send <label> "<command>"` — execute commands in specific sessions remotely
|
||||
- `par send all "<command>"` — broadcast to all sessions
|
||||
|
|
@ -87,6 +91,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
|
|||
- **Stack**: Python, tmux, HTTP server (localhost:9889)
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Supervisor + Workers**: Hierarchical model with three coordination patterns:
|
||||
- **Handoff**: Synchronous task transfer
|
||||
- **Assign**: Asynchronous spawning with callback
|
||||
|
|
@ -107,6 +112,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
|
|||
- **Stack**: Rust (2024 edition), ratatui TUI, Tokio async, OpenTelemetry
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **ProactiveMaster**: Orchestration core with zero shared state (message-passing channels)
|
||||
- **Specialized Agent Pools**: Frontend, Backend, DevOps, QA
|
||||
- **Multi-Provider Layer**: Claude Code, Aider, OpenAI Codex, custom tools
|
||||
|
|
@ -125,6 +131,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
|
|||
- **Stack**: Rust 92.8%, npm distribution
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Agent Client Protocol (ACP)**: Standardized interface across all agents
|
||||
- **Process Isolation**: Each agent in its own process with UDS socket
|
||||
- **Remote Access**: Interact with any agent from any terminal
|
||||
|
|
@ -143,6 +150,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
|
|||
- **Claims**: 100K+ monthly active users, 84.8% SWE-Bench solve rate
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Swarm Topologies**: mesh, hierarchical, ring, star configurations
|
||||
- **Queen-Led Hierarchies**: Strategic Queens (planning), Tactical Queens (execution), Adaptive Queens (optimization)
|
||||
- **8 Worker Types**: researcher, coder, analyst, tester, architect, reviewer, optimizer, documenter
|
||||
|
|
@ -153,6 +161,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
|
|||
- **Consensus Mechanisms**: Raft, Byzantine, Gossip, Weighted, Majority
|
||||
|
||||
**Extension System:**
|
||||
|
||||
- 17 integration hooks (pre-task, post-task, etc.)
|
||||
- Custom workers (12 context-triggered background services)
|
||||
- Plugin SDK with IPFS marketplace distribution
|
||||
|
|
@ -171,6 +180,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
|
|||
- **Stack**: Python 75.5%, TypeScript/React 22.3%, Docker, Kubernetes
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Software Agent SDK**: Composable Python library
|
||||
- **Runtime/Sandbox**: Docker-based sandboxed execution environments
|
||||
- **Event Stream Architecture**: Event-driven communication between backend and frontend
|
||||
|
|
@ -191,12 +201,14 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
|
|||
- **Stack**: Python 94.6%
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **SWEEnv**: Environment manager (thin wrapper around SWE-ReX)
|
||||
- **Agent**: Configured via single YAML file
|
||||
- **ACI (Agent-Computer Interface)**: Custom tools installed in container
|
||||
- **Deployment**: Abstraction over execution targets
|
||||
|
||||
**Runtime Backends (SWE-ReX):**
|
||||
|
||||
- Local Docker containers
|
||||
- Modal (serverless compute)
|
||||
- AWS Fargate (container orchestration)
|
||||
|
|
@ -216,6 +228,7 @@ Agent code remains the same regardless of deployment target.
|
|||
- **Stack**: Rust 58.9%, TypeScript 33.0%, Go (temporal scheduler)
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Crate architecture**: goose (core), goose-cli, goose-server, goose-mcp, mcp-client, mcp-core
|
||||
- **Sessions**: Stateful autonomous execution environments
|
||||
- **Recipes**: Task automation workflows
|
||||
|
|
@ -233,6 +246,7 @@ Agent code remains the same regardless of deployment target.
|
|||
- **Stack**: TypeScript, Node.js, esbuild
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Sequential Decision Loop**: Analysis → Planning → Execution → Monitoring → Iteration
|
||||
- **Checkpoint System**: Workspace snapshots at each step for compare/restore
|
||||
- **Context Attachments**: @file, @folder, @url, @problems
|
||||
|
|
@ -249,6 +263,7 @@ Agent code remains the same regardless of deployment target.
|
|||
- **Reached #13 on Stanford's TerminalBench**
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Orchestrator Agent**: Strategic coordinator; never touches code
|
||||
- **Explorer Agent**: Read-only investigation specialist
|
||||
- **Coder Agent**: Implementation specialist with write access
|
||||
|
|
@ -270,6 +285,7 @@ Agent code remains the same regardless of deployment target.
|
|||
- **Stack**: Python, GitHub REST API, Claude Code
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **5-Phase Workflow**: Brainstorm → Document → Plan → Decompose → Execute
|
||||
- **GitHub Issues as Database**: Issues store specs, comments provide audit trail
|
||||
- **Epic Worktrees**: Each epic spawns a dedicated worktree
|
||||
|
|
@ -283,6 +299,7 @@ Agent code remains the same regardless of deployment target.
|
|||
- **Stack**: Python (Flask + Socket.IO), Vue 3 + Vite, Docker/Kubernetes
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Workflow Presets**: Default (Codex→Gemini→Claude), Quick, Thorough, Review-Only, Document
|
||||
- **AI Adapters**: Standardized interfaces per agent tool
|
||||
- **Session Manager**: Context across workflow steps
|
||||
|
|
@ -296,6 +313,7 @@ Agent code remains the same regardless of deployment target.
|
|||
- **Stack**: Claude Code plugin ecosystem
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Plugins**: 73 plugins, 112 agents, 146 skills, 79 tools
|
||||
- **Progressive Disclosure Skills**: 3-tier knowledge
|
||||
- **16 Workflow Orchestrators**: review, debug, feature, fullstack, research, security, migration
|
||||
|
|
@ -309,7 +327,7 @@ Agent code remains the same regardless of deployment target.
|
|||
### Cloud Sandbox Platforms
|
||||
|
||||
| Platform | Startup Time | Isolation | API Style | Cost |
|
||||
|----------|-------------|-----------|-----------|------|
|
||||
| ------------------- | ------------ | -------------------- | -------------------- | ----------- |
|
||||
| **Docker (local)** | ~1-5s | Container namespace | Docker CLI/API | Free |
|
||||
| **E2B** | ~200-400ms | Firecracker microVMs | Python/JS SDK | Pay-per-use |
|
||||
| **Daytona** | ~27-90ms | OCI containers | Python/TS SDK + REST | Open source |
|
||||
|
|
@ -319,12 +337,14 @@ Agent code remains the same regardless of deployment target.
|
|||
### Agent-Sandbox Connection Patterns (per LangChain)
|
||||
|
||||
**Pattern 1: Agent IN Sandbox**
|
||||
|
||||
- Agent runs inside the container/VM
|
||||
- Communicates outward via HTTP/WebSocket
|
||||
- Pro: Direct filesystem access, mirrors local dev
|
||||
- Con: API keys inside sandbox
|
||||
|
||||
**Pattern 2: Sandbox AS Tool**
|
||||
|
||||
- Agent runs on orchestrator/server
|
||||
- Calls sandbox via SDK/API for code execution
|
||||
- Pro: API keys secure, parallel execution
|
||||
|
|
@ -333,7 +353,7 @@ Agent code remains the same regardless of deployment target.
|
|||
### Communication Protocols
|
||||
|
||||
| Protocol | Use Case | Used By |
|
||||
|----------|----------|---------|
|
||||
| -------------------- | ----------------------- | -------------------------------- |
|
||||
| **REST API** | Request/response | OpenHands, Fly.io, Daytona |
|
||||
| **WebSocket** | Bidirectional streaming | OpenHands, Claude Agent SDK |
|
||||
| **stdio/subprocess** | Child process | Claude Agent SDK, Codex CLI, MCP |
|
||||
|
|
@ -343,7 +363,7 @@ Agent code remains the same regardless of deployment target.
|
|||
### Heartbeat / Health Detection
|
||||
|
||||
| Pattern | Description | Used By |
|
||||
|---------|-------------|---------|
|
||||
| ------------------------ | ------------------------------------ | --------------------------- |
|
||||
| **WebSocket ping/pong** | Periodic heartbeats | OpenHands |
|
||||
| **Process polling** | Check PID alive | Claude Agent SDK |
|
||||
| **tmux capture-pane** | Scrape terminal output | Our `claude-session-status` |
|
||||
|
|
@ -356,12 +376,14 @@ Agent code remains the same regardless of deployment target.
|
|||
## Key Findings & Gaps
|
||||
|
||||
### What Everyone Does
|
||||
|
||||
1. **Git worktrees** = standard isolation primitive
|
||||
2. **tmux** = dominant session manager for local
|
||||
3. **External state > context windows** (Beads, Context Store, GitHub Issues)
|
||||
4. **MCP** = emerging extension protocol
|
||||
|
||||
### What Nobody Does Well (Our Opportunity)
|
||||
|
||||
1. **Multiple runtime backends** (tmux + Docker + cloud) with same interface
|
||||
2. **Multiple agent support** with proper abstraction
|
||||
3. **Human-in-the-loop optimization** (our core differentiator — everyone else optimizes for autonomous)
|
||||
|
|
@ -371,6 +393,7 @@ Agent code remains the same regardless of deployment target.
|
|||
7. **Full PR lifecycle management** (CI checks, review comments, merge readiness, auto-reactions)
|
||||
|
||||
### Best Ideas to Steal
|
||||
|
||||
- **Gas Town**: Git-backed state (Beads), role-based agents, crash recovery
|
||||
- **OpenHands**: Event-sourced architecture, Docker/K8s runtime abstraction
|
||||
- **SWE-ReX**: Clean deployment backend interface (`swe-rex[modal]`, `swe-rex[fargate]`)
|
||||
|
|
|
|||
|
|
@ -33,11 +33,13 @@
|
|||
**Must be done first. Everything else depends on it.**
|
||||
|
||||
Creates the monorepo scaffold and ALL type definitions. After this, every agent has:
|
||||
|
||||
- A package to work in (with package.json, tsconfig)
|
||||
- All interfaces defined (they just implement them)
|
||||
- No ambiguity about what to build
|
||||
|
||||
### Deliverables
|
||||
|
||||
1. `pnpm-workspace.yaml` + root `package.json` + `tsconfig.base.json`
|
||||
2. `packages/core/src/types.ts` — ALL interfaces (Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal, Session, Event, Config, etc.)
|
||||
3. `packages/core/src/config.ts` — Zod schemas for YAML config validation
|
||||
|
|
@ -54,13 +56,14 @@ Creates the monorepo scaffold and ALL type definitions. After this, every agent
|
|||
## Phase 1: Parallel Implementation (7 Agents)
|
||||
|
||||
### Agent 1: Core Services
|
||||
|
||||
**Package**: `packages/core/src/`
|
||||
**Branch**: `feat/core-services`
|
||||
**Depends on**: Phase 0 types
|
||||
**Blocked by**: Nothing after Phase 0
|
||||
|
||||
| File | What | Reference Script |
|
||||
|------|------|-----------------|
|
||||
| ---------------------- | ---------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `metadata.ts` | Flat-file metadata read/write (key=value) | Metadata parsing in all session managers |
|
||||
| `event-bus.ts` | In-process pub/sub + JSONL persistence | New (inspired by OpenHands event stream) |
|
||||
| `tmux.ts` | tmux command wrappers (list, new, send-keys, capture-pane, kill) | All scripts that call tmux |
|
||||
|
|
@ -74,13 +77,14 @@ Creates the monorepo scaffold and ALL type definitions. After this, every agent
|
|||
---
|
||||
|
||||
### Agent 2: Runtime + Workspace Plugins
|
||||
|
||||
**Packages**: `packages/plugins/runtime-tmux/`, `runtime-process/`, `workspace-worktree/`, `workspace-clone/`
|
||||
**Branch**: `feat/runtime-workspace-plugins`
|
||||
**Depends on**: Phase 0 types only
|
||||
**Blocked by**: Nothing after Phase 0
|
||||
|
||||
| Plugin | What | Reference |
|
||||
|--------|------|-----------|
|
||||
| -------------------- | ------------------------------------------------------------------ | ---------------------------------- |
|
||||
| `runtime-tmux` | Create/destroy tmux sessions, send-keys, capture-pane, alive check | `claude-ao-session` new/kill |
|
||||
| `runtime-process` | Spawn child processes, stdin/stdout, signal handling | New (for headless `claude -p`) |
|
||||
| `workspace-worktree` | `git worktree add/remove/list`, branch naming, symlinks | `claude-ao-session` worktree logic |
|
||||
|
|
@ -93,13 +97,14 @@ Creates the monorepo scaffold and ALL type definitions. After this, every agent
|
|||
---
|
||||
|
||||
### Agent 3: Agent Plugins
|
||||
|
||||
**Packages**: `packages/plugins/agent-claude-code/`, `agent-codex/`, `agent-aider/`
|
||||
**Branch**: `feat/agent-plugins`
|
||||
**Depends on**: Phase 0 types only
|
||||
**Blocked by**: Nothing after Phase 0
|
||||
|
||||
| Plugin | What | Reference |
|
||||
|--------|------|-----------|
|
||||
| ------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `agent-claude-code` | Launch cmd, JSONL activity detection, process tree walk, introspection | `claude-status`, `get-claude-session-info`, `claude-session-status` |
|
||||
| `agent-codex` | Launch cmd, process detection | New |
|
||||
| `agent-aider` | Launch cmd, process detection | New |
|
||||
|
|
@ -111,13 +116,14 @@ Creates the monorepo scaffold and ALL type definitions. After this, every agent
|
|||
---
|
||||
|
||||
### Agent 4: SCM + Tracker Plugins
|
||||
|
||||
**Packages**: `packages/plugins/scm-github/`, `tracker-github/`, `tracker-linear/`
|
||||
**Branch**: `feat/scm-tracker-plugins`
|
||||
**Depends on**: Phase 0 types only
|
||||
**Blocked by**: Nothing after Phase 0
|
||||
|
||||
| Plugin | What | Reference |
|
||||
|--------|------|-----------|
|
||||
| ---------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `scm-github` | PR detection, CI checks, review comments, automated comments, merge readiness, merge | `claude-review-check`, `claude-bugbot-fix`, dashboard PR fetching |
|
||||
| `tracker-github` | Issue fetch, completion check, branch naming, prompt generation | `claude-splitly-session` (GitHub Issues) |
|
||||
| `tracker-linear` | Issue fetch via GraphQL, completion check, branch naming | `claude-ao-session` + `claude-integrator-session` Linear checks |
|
||||
|
|
@ -129,13 +135,14 @@ Creates the monorepo scaffold and ALL type definitions. After this, every agent
|
|||
---
|
||||
|
||||
### Agent 5: CLI
|
||||
|
||||
**Package**: `packages/cli/`
|
||||
**Branch**: `feat/cli`
|
||||
**Depends on**: Phase 0 types + core interfaces (codes against interfaces, wires up when core is ready)
|
||||
**Partially blocked by**: Agent 1 (core services) for runtime testing
|
||||
|
||||
| Command | What | Reference Script |
|
||||
|---------|------|-----------------|
|
||||
| -------------------------------------- | ---------------------------------------------------- | ----------------------------------- |
|
||||
| `ao init` | Interactive setup wizard → `agent-orchestrator.yaml` | New |
|
||||
| `ao status` | Colored terminal table of all sessions | `claude-status` |
|
||||
| `ao spawn <project> [issue]` | Spawn single session | `claude-spawn` |
|
||||
|
|
@ -155,13 +162,14 @@ Creates the monorepo scaffold and ALL type definitions. After this, every agent
|
|||
---
|
||||
|
||||
### Agent 6: Web Dashboard
|
||||
|
||||
**Package**: `packages/web/`
|
||||
**Branch**: `feat/web-dashboard`
|
||||
**Depends on**: Phase 0 types + core interfaces
|
||||
**Partially blocked by**: Agent 1 (core services) for API routes
|
||||
|
||||
| Component | What | Reference |
|
||||
|-----------|------|-----------|
|
||||
| ----------------------------- | --------------------------------------------- | -------------------------------- |
|
||||
| Next.js setup | App Router, Tailwind, dark theme | New |
|
||||
| `GET /api/sessions` | List all sessions with full state | `claude-dashboard` /api/sessions |
|
||||
| `POST /api/spawn` | Spawn new session | New |
|
||||
|
|
@ -182,13 +190,14 @@ Creates the monorepo scaffold and ALL type definitions. After this, every agent
|
|||
---
|
||||
|
||||
### Agent 7: Notifier + Terminal Plugins
|
||||
|
||||
**Packages**: `packages/plugins/notifier-desktop/`, `notifier-slack/`, `notifier-webhook/`, `terminal-iterm2/`, `terminal-web/`
|
||||
**Branch**: `feat/notifier-terminal-plugins`
|
||||
**Depends on**: Phase 0 types only
|
||||
**Blocked by**: Nothing after Phase 0
|
||||
|
||||
| Plugin | What | Reference |
|
||||
|--------|------|-----------|
|
||||
| ------------------ | ---------------------------------------------------------------- | ----------------------------------- |
|
||||
| `notifier-desktop` | OS notifications (node-notifier), click → deep link to dashboard | `notify-session` |
|
||||
| `notifier-slack` | Slack webhook messages with action buttons | New |
|
||||
| `notifier-webhook` | Generic HTTP POST | New |
|
||||
|
|
@ -225,6 +234,7 @@ Phase 2 (integration): after all agents done
|
|||
### True Independence
|
||||
|
||||
These agents are **truly independent** after Phase 0:
|
||||
|
||||
- Agents 2, 3, 4, 7 implement plugin interfaces → zero inter-dependency
|
||||
- Agent 1 (core) is the critical path
|
||||
- Agents 5, 6 can start with mock/interface-only imports, wire later
|
||||
|
|
@ -232,6 +242,7 @@ These agents are **truly independent** after Phase 0:
|
|||
### Risk: Agent 1 (Core) Is the Bottleneck
|
||||
|
||||
If core services are delayed, CLI and Web can't fully test. Mitigations:
|
||||
|
||||
- Agent 1 gets the most experienced agent
|
||||
- Phase 0 writes enough core scaffolding (types, config, plugin-registry) that other agents aren't waiting
|
||||
- CLI and Web start with mock implementations
|
||||
|
|
@ -241,7 +252,7 @@ If core services are delayed, CLI and Web can't fully test. Mitigations:
|
|||
## Linear Tickets (for spawning)
|
||||
|
||||
| Ticket | Title | Agent |
|
||||
|--------|-------|-------|
|
||||
| ------ | ------------------------------------------------------------------------------------- | ----- |
|
||||
| AO-10 | Implement core services (metadata, event-bus, session-manager, lifecycle-manager) | 1 |
|
||||
| AO-11 | Implement runtime + workspace plugins (tmux, process, worktree, clone) | 2 |
|
||||
| AO-12 | Implement agent plugins (claude-code, codex, aider) | 3 |
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export default tseslint.config(
|
|||
"no-template-curly-in-string": "warn",
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
"eqeqeq": ["error", "always"],
|
||||
eqeqeq: ["error", "always"],
|
||||
|
||||
// TypeScript
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
|
|
@ -51,10 +51,7 @@ export default tseslint.config(
|
|||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{ prefer: "type-imports" },
|
||||
],
|
||||
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
"@typescript-eslint/no-require-imports": "error",
|
||||
},
|
||||
|
|
@ -76,5 +73,5 @@ export default tseslint.config(
|
|||
rules: {
|
||||
"no-console": "off", // Next.js uses console for server logs
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createEventBus, createEvent } from "../event-bus.js";
|
||||
import type { OrchestratorEvent, EventType } from "../types.js";
|
||||
import type { OrchestratorEvent } from "../types.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
|
|
@ -36,16 +36,37 @@ describe("createEvent", () => {
|
|||
});
|
||||
|
||||
it("infers priority from event type", () => {
|
||||
expect(createEvent("session.stuck", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("urgent");
|
||||
expect(createEvent("session.needs_input", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("urgent");
|
||||
expect(createEvent("session.errored", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("urgent");
|
||||
expect(createEvent("review.approved", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("action");
|
||||
expect(createEvent("merge.ready", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("action");
|
||||
expect(createEvent("merge.completed", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("action");
|
||||
expect(createEvent("ci.failing", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("warning");
|
||||
expect(createEvent("review.changes_requested", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("warning");
|
||||
expect(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("info");
|
||||
expect(createEvent("pr.created", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("info");
|
||||
expect(
|
||||
createEvent("session.stuck", { sessionId: "a", projectId: "p", message: "" }).priority,
|
||||
).toBe("urgent");
|
||||
expect(
|
||||
createEvent("session.needs_input", { sessionId: "a", projectId: "p", message: "" }).priority,
|
||||
).toBe("urgent");
|
||||
expect(
|
||||
createEvent("session.errored", { sessionId: "a", projectId: "p", message: "" }).priority,
|
||||
).toBe("urgent");
|
||||
expect(
|
||||
createEvent("review.approved", { sessionId: "a", projectId: "p", message: "" }).priority,
|
||||
).toBe("action");
|
||||
expect(
|
||||
createEvent("merge.ready", { sessionId: "a", projectId: "p", message: "" }).priority,
|
||||
).toBe("action");
|
||||
expect(
|
||||
createEvent("merge.completed", { sessionId: "a", projectId: "p", message: "" }).priority,
|
||||
).toBe("action");
|
||||
expect(
|
||||
createEvent("ci.failing", { sessionId: "a", projectId: "p", message: "" }).priority,
|
||||
).toBe("warning");
|
||||
expect(
|
||||
createEvent("review.changes_requested", { sessionId: "a", projectId: "p", message: "" })
|
||||
.priority,
|
||||
).toBe("warning");
|
||||
expect(
|
||||
createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }).priority,
|
||||
).toBe("info");
|
||||
expect(
|
||||
createEvent("pr.created", { sessionId: "a", projectId: "p", message: "" }).priority,
|
||||
).toBe("info");
|
||||
});
|
||||
|
||||
it("allows explicit priority override", () => {
|
||||
|
|
@ -124,7 +145,9 @@ describe("createEventBus (no persistence)", () => {
|
|||
const bus = createEventBus(null);
|
||||
const received: OrchestratorEvent[] = [];
|
||||
|
||||
bus.on("session.spawned", () => { throw new Error("boom"); });
|
||||
bus.on("session.spawned", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
bus.on("session.spawned", (e) => received.push(e));
|
||||
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
|
||||
|
|
@ -206,7 +229,13 @@ describe("getHistory", () => {
|
|||
const bus = createEventBus(null);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
bus.emit(createEvent("session.spawned", { sessionId: `s-${i}`, projectId: "p", message: `msg-${i}` }));
|
||||
bus.emit(
|
||||
createEvent("session.spawned", {
|
||||
sessionId: `s-${i}`,
|
||||
projectId: "p",
|
||||
message: `msg-${i}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const result = bus.getHistory({ limit: 3 });
|
||||
|
|
@ -225,7 +254,9 @@ describe("getHistory", () => {
|
|||
|
||||
expect(bus.getHistory({ sessionId: "a", projectId: "p1" })).toHaveLength(2);
|
||||
expect(bus.getHistory({ sessionId: "a", type: "session.spawned" })).toHaveLength(2);
|
||||
expect(bus.getHistory({ sessionId: "a", projectId: "p1", type: "session.spawned" })).toHaveLength(1);
|
||||
expect(
|
||||
bus.getHistory({ sessionId: "a", projectId: "p1", type: "session.spawned" }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -247,11 +278,16 @@ describe("JSONL persistence", () => {
|
|||
expect(parsed.timestamp).toBeTruthy();
|
||||
});
|
||||
|
||||
it("loads history from existing JSONL file on creation", () => {
|
||||
it("loads history from existing JSONL file on creation", async () => {
|
||||
const logPath = join(tmpDir, "existing.jsonl");
|
||||
const event = createEvent("session.spawned", { sessionId: "old", projectId: "p", message: "old event" });
|
||||
const event = createEvent("session.spawned", {
|
||||
sessionId: "old",
|
||||
projectId: "p",
|
||||
message: "old event",
|
||||
});
|
||||
const serialized = JSON.stringify({ ...event, timestamp: event.timestamp.toISOString() });
|
||||
require("node:fs").writeFileSync(logPath, serialized + "\n", "utf-8");
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
writeFileSync(logPath, serialized + "\n", "utf-8");
|
||||
|
||||
const bus = createEventBus(logPath);
|
||||
const history = bus.getHistory();
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import type {
|
|||
Runtime,
|
||||
Agent,
|
||||
SCM,
|
||||
Notifier,
|
||||
EventBus,
|
||||
OrchestratorEvent,
|
||||
ActivityState,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
|
@ -100,7 +100,7 @@ describe("readMetadataRaw", () => {
|
|||
writeFileSync(
|
||||
join(dataDir, "sessions", "raw-1"),
|
||||
"worktree=/tmp/w\nbranch=main\ncustom_key=custom_value\n",
|
||||
"utf-8"
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const raw = readMetadataRaw(dataDir, "raw-1");
|
||||
|
|
@ -117,7 +117,7 @@ describe("readMetadataRaw", () => {
|
|||
writeFileSync(
|
||||
join(dataDir, "sessions", "raw-2"),
|
||||
"# This is a comment\n\nkey1=value1\n\n# Another comment\nkey2=value2\n",
|
||||
"utf-8"
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const raw = readMetadataRaw(dataDir, "raw-2");
|
||||
|
|
@ -128,7 +128,7 @@ describe("readMetadataRaw", () => {
|
|||
writeFileSync(
|
||||
join(dataDir, "sessions", "raw-3"),
|
||||
'runtimeHandle={"id":"foo","data":{"key":"val"}}\n',
|
||||
"utf-8"
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const raw = readMetadataRaw(dataDir, "raw-3");
|
||||
|
|
@ -144,7 +144,10 @@ describe("updateMetadata", () => {
|
|||
status: "spawning",
|
||||
});
|
||||
|
||||
updateMetadata(dataDir, "upd-1", { status: "working", pr: "https://github.com/org/repo/pull/1" });
|
||||
updateMetadata(dataDir, "upd-1", {
|
||||
status: "working",
|
||||
pr: "https://github.com/org/repo/pull/1",
|
||||
});
|
||||
|
||||
const meta = readMetadata(dataDir, "upd-1");
|
||||
expect(meta!.status).toBe("working");
|
||||
|
|
@ -203,7 +206,7 @@ describe("deleteMetadata", () => {
|
|||
expect(existsSync(join(dataDir, "sessions", "del-1"))).toBe(false);
|
||||
const archiveDir = join(dataDir, "sessions", "archive");
|
||||
expect(existsSync(archiveDir)).toBe(true);
|
||||
const files = require("node:fs").readdirSync(archiveDir) as string[];
|
||||
const files = readdirSync(archiveDir);
|
||||
expect(files.length).toBe(1);
|
||||
expect(files[0]).toMatch(/^del-1_/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createSessionManager } from "../session-manager.js";
|
||||
import { createEventBus } from "../event-bus.js";
|
||||
import { writeMetadata, readMetadata, listMetadata } from "../metadata.js";
|
||||
import { writeMetadata, readMetadata } from "../metadata.js";
|
||||
import type {
|
||||
OrchestratorConfig,
|
||||
PluginRegistry,
|
||||
|
|
@ -70,7 +70,7 @@ beforeEach(() => {
|
|||
|
||||
mockRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string, name: string) => {
|
||||
get: vi.fn().mockImplementation((slot: string, _name: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return mockAgent;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
|
|
@ -496,10 +496,7 @@ describe("send", () => {
|
|||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
await sm.send("app-1", "Fix the CI failures");
|
||||
|
||||
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(
|
||||
makeHandle("rt-1"),
|
||||
"Fix the CI failures"
|
||||
);
|
||||
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(makeHandle("rt-1"), "Fix the CI failures");
|
||||
});
|
||||
|
||||
it("throws for nonexistent session", async () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import * as childProcess from "node:child_process";
|
||||
import {
|
||||
isTmuxAvailable,
|
||||
|
|
@ -18,19 +18,21 @@ vi.mock("node:child_process", () => ({
|
|||
|
||||
const mockExecFile = vi.mocked(childProcess.execFile);
|
||||
|
||||
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void;
|
||||
|
||||
/** Helper to make execFile resolve with stdout. */
|
||||
function mockTmuxSuccess(stdout: string) {
|
||||
mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
|
||||
(callback as Function)(null, stdout, "");
|
||||
return {} as any;
|
||||
(callback as ExecFileCallback)(null, stdout, "");
|
||||
return {} as ReturnType<typeof childProcess.execFile>;
|
||||
});
|
||||
}
|
||||
|
||||
/** Helper to make execFile reject with an error. */
|
||||
function mockTmuxError(message: string) {
|
||||
mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
|
||||
(callback as Function)(new Error(message), "", message);
|
||||
return {} as any;
|
||||
(callback as ExecFileCallback)(new Error(message), "", message);
|
||||
return {} as ReturnType<typeof childProcess.execFile>;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -41,11 +43,11 @@ function mockTmuxSequence(results: Array<{ stdout?: string; error?: string }>) {
|
|||
const result = results[callIndex] ?? results[results.length - 1];
|
||||
callIndex++;
|
||||
if (result.error) {
|
||||
(callback as Function)(new Error(result.error), "", result.error);
|
||||
(callback as ExecFileCallback)(new Error(result.error), "", result.error);
|
||||
} else {
|
||||
(callback as Function)(null, result.stdout ?? "", "");
|
||||
(callback as ExecFileCallback)(null, result.stdout ?? "", "");
|
||||
}
|
||||
return {} as any;
|
||||
return {} as ReturnType<typeof childProcess.execFile>;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -68,8 +70,7 @@ describe("isTmuxAvailable", () => {
|
|||
describe("listSessions", () => {
|
||||
it("parses tmux session list", async () => {
|
||||
mockTmuxSuccess(
|
||||
"app-1\tMon Jan 1 00:00:00 2025\t0\t2\n" +
|
||||
"app-2\tTue Jan 2 00:00:00 2025\t1\t1\n"
|
||||
"app-1\tMon Jan 1 00:00:00 2025\t0\t2\n" + "app-2\tTue Jan 2 00:00:00 2025\t1\t1\n",
|
||||
);
|
||||
|
||||
const sessions = await listSessions();
|
||||
|
|
@ -107,7 +108,7 @@ describe("hasSession", () => {
|
|||
"tmux",
|
||||
["has-session", "-t", "app-1"],
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -127,7 +128,7 @@ describe("newSession", () => {
|
|||
"tmux",
|
||||
["new-session", "-d", "-s", "test-1", "-c", "/tmp/workspace"],
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -160,12 +161,7 @@ describe("newSession", () => {
|
|||
|
||||
it("sends initial command after creation", async () => {
|
||||
// Calls: new-session, send-keys Escape, send-keys text, send-keys Enter
|
||||
mockTmuxSequence([
|
||||
{ stdout: "" },
|
||||
{ stdout: "" },
|
||||
{ stdout: "" },
|
||||
{ stdout: "" },
|
||||
]);
|
||||
mockTmuxSequence([{ stdout: "" }, { stdout: "" }, { stdout: "" }, { stdout: "" }]);
|
||||
|
||||
await newSession({ name: "test-4", cwd: "/tmp", command: "echo hello" });
|
||||
|
||||
|
|
@ -274,7 +270,7 @@ describe("capturePane", () => {
|
|||
"tmux",
|
||||
["capture-pane", "-t", "app-1", "-p", "-S", "-30"],
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -298,7 +294,7 @@ describe("killSession", () => {
|
|||
"tmux",
|
||||
["kill-session", "-t", "app-1"],
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -88,9 +88,7 @@ const OrchestratorConfigSchema = z.object({
|
|||
defaults: DefaultPluginsSchema.default({}),
|
||||
projects: z.record(ProjectConfigSchema),
|
||||
notifiers: z.record(NotifierConfigSchema).default({}),
|
||||
notificationRouting: z
|
||||
.record(z.array(z.string()))
|
||||
.default({
|
||||
notificationRouting: z.record(z.array(z.string())).default({
|
||||
urgent: ["desktop", "slack"],
|
||||
action: ["desktop", "slack"],
|
||||
warning: ["slack"],
|
||||
|
|
@ -124,9 +122,7 @@ function expandPaths(config: OrchestratorConfig): OrchestratorConfig {
|
|||
}
|
||||
|
||||
/** Apply defaults to project configs */
|
||||
function applyProjectDefaults(
|
||||
config: OrchestratorConfig
|
||||
): OrchestratorConfig {
|
||||
function applyProjectDefaults(config: OrchestratorConfig): OrchestratorConfig {
|
||||
for (const [id, project] of Object.entries(config.projects)) {
|
||||
// Derive name from project ID if not set
|
||||
if (!project.name) {
|
||||
|
|
@ -153,10 +149,8 @@ function applyProjectDefaults(
|
|||
}
|
||||
|
||||
/** Apply default reactions */
|
||||
function applyDefaultReactions(
|
||||
config: OrchestratorConfig
|
||||
): OrchestratorConfig {
|
||||
const defaults: Record<string, typeof config.reactions[string]> = {
|
||||
function applyDefaultReactions(config: OrchestratorConfig): OrchestratorConfig {
|
||||
const defaults: Record<string, (typeof config.reactions)[string]> = {
|
||||
"ci-failed": {
|
||||
auto: true,
|
||||
action: "send-to-agent",
|
||||
|
|
@ -175,15 +169,13 @@ function applyDefaultReactions(
|
|||
"bugbot-comments": {
|
||||
auto: true,
|
||||
action: "send-to-agent",
|
||||
message:
|
||||
"Automated review comments found on your PR. Fix the issues flagged by the bot.",
|
||||
message: "Automated review comments found on your PR. Fix the issues flagged by the bot.",
|
||||
escalateAfter: "30m",
|
||||
},
|
||||
"merge-conflicts": {
|
||||
auto: true,
|
||||
action: "send-to-agent",
|
||||
message:
|
||||
"Your branch has merge conflicts. Rebase on the default branch and resolve them.",
|
||||
message: "Your branch has merge conflicts. Rebase on the default branch and resolve them.",
|
||||
escalateAfter: "15m",
|
||||
},
|
||||
"approved-and-green": {
|
||||
|
|
@ -252,9 +244,7 @@ export function loadConfig(configPath?: string): OrchestratorConfig {
|
|||
const path = configPath ?? findConfigFile();
|
||||
|
||||
if (!path) {
|
||||
throw new Error(
|
||||
"No agent-orchestrator.yaml found. Run `ao init` to create one."
|
||||
);
|
||||
throw new Error("No agent-orchestrator.yaml found. Run `ao init` to create one.");
|
||||
}
|
||||
|
||||
const raw = readFileSync(path, "utf-8");
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*/
|
||||
|
||||
import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { dirname } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
EventBus,
|
||||
|
|
@ -33,7 +33,7 @@ export function createEvent(
|
|||
message: string;
|
||||
priority?: EventPriority;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
},
|
||||
): OrchestratorEvent {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
|
|
@ -52,7 +52,12 @@ function inferPriority(type: EventType): EventPriority {
|
|||
if (type.includes("stuck") || type.includes("needs_input") || type.includes("errored")) {
|
||||
return "urgent";
|
||||
}
|
||||
if (type.includes("approved") || type.includes("ready") || type.includes("merged") || type.includes("completed")) {
|
||||
if (
|
||||
type.includes("approved") ||
|
||||
type.includes("ready") ||
|
||||
type.includes("merged") ||
|
||||
type.includes("completed")
|
||||
) {
|
||||
return "action";
|
||||
}
|
||||
if (type.includes("fail") || type.includes("changes_requested") || type.includes("conflicts")) {
|
||||
|
|
|
|||
|
|
@ -40,48 +40,72 @@ function parseDuration(str: string): number {
|
|||
if (!match) return 0;
|
||||
const value = parseInt(match[1], 10);
|
||||
switch (match[2]) {
|
||||
case "s": return value * 1000;
|
||||
case "m": return value * 60_000;
|
||||
case "h": return value * 3_600_000;
|
||||
default: return 0;
|
||||
case "s":
|
||||
return value * 1000;
|
||||
case "m":
|
||||
return value * 60_000;
|
||||
case "h":
|
||||
return value * 3_600_000;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Determine which event type corresponds to a status transition. */
|
||||
function statusToEventType(
|
||||
_from: SessionStatus | undefined,
|
||||
to: SessionStatus
|
||||
): EventType | null {
|
||||
function statusToEventType(_from: SessionStatus | undefined, to: SessionStatus): EventType | null {
|
||||
switch (to) {
|
||||
case "working": return "session.working";
|
||||
case "pr_open": return "pr.created";
|
||||
case "ci_failed": return "ci.failing";
|
||||
case "review_pending": return "review.pending";
|
||||
case "changes_requested": return "review.changes_requested";
|
||||
case "approved": return "review.approved";
|
||||
case "mergeable": return "merge.ready";
|
||||
case "merged": return "merge.completed";
|
||||
case "needs_input": return "session.needs_input";
|
||||
case "stuck": return "session.stuck";
|
||||
case "errored": return "session.errored";
|
||||
case "killed": return "session.killed";
|
||||
default: return null;
|
||||
case "working":
|
||||
return "session.working";
|
||||
case "pr_open":
|
||||
return "pr.created";
|
||||
case "ci_failed":
|
||||
return "ci.failing";
|
||||
case "review_pending":
|
||||
return "review.pending";
|
||||
case "changes_requested":
|
||||
return "review.changes_requested";
|
||||
case "approved":
|
||||
return "review.approved";
|
||||
case "mergeable":
|
||||
return "merge.ready";
|
||||
case "merged":
|
||||
return "merge.completed";
|
||||
case "needs_input":
|
||||
return "session.needs_input";
|
||||
case "stuck":
|
||||
return "session.stuck";
|
||||
case "errored":
|
||||
return "session.errored";
|
||||
case "killed":
|
||||
return "session.killed";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map event type to reaction config key. */
|
||||
function eventToReactionKey(eventType: EventType): string | null {
|
||||
switch (eventType) {
|
||||
case "ci.failing": return "ci-failed";
|
||||
case "review.changes_requested": return "changes-requested";
|
||||
case "automated_review.found": return "bugbot-comments";
|
||||
case "merge.conflicts": return "merge-conflicts";
|
||||
case "merge.ready": return "approved-and-green";
|
||||
case "session.stuck": return "agent-stuck";
|
||||
case "session.needs_input": return "agent-needs-input";
|
||||
case "session.killed": return "agent-exited";
|
||||
case "summary.all_complete": return "all-complete";
|
||||
default: return null;
|
||||
case "ci.failing":
|
||||
return "ci-failed";
|
||||
case "review.changes_requested":
|
||||
return "changes-requested";
|
||||
case "automated_review.found":
|
||||
return "bugbot-comments";
|
||||
case "merge.conflicts":
|
||||
return "merge-conflicts";
|
||||
case "merge.ready":
|
||||
return "approved-and-green";
|
||||
case "session.stuck":
|
||||
return "agent-stuck";
|
||||
case "session.needs_input":
|
||||
return "agent-needs-input";
|
||||
case "session.killed":
|
||||
return "agent-exited";
|
||||
case "summary.all_complete":
|
||||
return "all-complete";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,9 +123,7 @@ interface ReactionTracker {
|
|||
}
|
||||
|
||||
/** Create a LifecycleManager instance. */
|
||||
export function createLifecycleManager(
|
||||
deps: LifecycleManagerDeps
|
||||
): LifecycleManager {
|
||||
export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleManager {
|
||||
const { config, registry, sessionManager, eventBus } = deps;
|
||||
|
||||
const states = new Map<SessionId, SessionStatus>();
|
||||
|
|
@ -124,13 +146,21 @@ export function createLifecycleManager(
|
|||
const typeHandlers = handlers.get(event.type);
|
||||
if (typeHandlers) {
|
||||
for (const handler of typeHandlers) {
|
||||
try { handler(event); } catch { /* ignore */ }
|
||||
try {
|
||||
handler(event);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
const wildcardHandlers = handlers.get("*");
|
||||
if (wildcardHandlers) {
|
||||
for (const handler of wildcardHandlers) {
|
||||
try { handler(event); } catch { /* ignore */ }
|
||||
try {
|
||||
handler(event);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -140,20 +170,12 @@ export function createLifecycleManager(
|
|||
const project = config.projects[session.projectId];
|
||||
if (!project) return session.status;
|
||||
|
||||
const agent = registry.get<Agent>(
|
||||
"agent",
|
||||
project.agent ?? config.defaults.agent
|
||||
);
|
||||
const scm = project.scm
|
||||
? registry.get<SCM>("scm", project.scm.plugin)
|
||||
: null;
|
||||
const agent = registry.get<Agent>("agent", project.agent ?? config.defaults.agent);
|
||||
const scm = project.scm ? registry.get<SCM>("scm", project.scm.plugin) : null;
|
||||
|
||||
// 1. Check if runtime is alive
|
||||
if (session.runtimeHandle) {
|
||||
const runtime = registry.get<Runtime>(
|
||||
"runtime",
|
||||
project.runtime ?? config.defaults.runtime
|
||||
);
|
||||
const runtime = registry.get<Runtime>("runtime", project.runtime ?? config.defaults.runtime);
|
||||
if (runtime) {
|
||||
const alive = await runtime.isAlive(session.runtimeHandle).catch(() => true);
|
||||
if (!alive) return "killed";
|
||||
|
|
@ -204,7 +226,7 @@ export function createLifecycleManager(
|
|||
async function executeReaction(
|
||||
event: OrchestratorEvent,
|
||||
reactionKey: string,
|
||||
reactionConfig: ReactionConfig
|
||||
reactionConfig: ReactionConfig,
|
||||
): Promise<ReactionResult> {
|
||||
const trackerKey = `${event.sessionId}:${reactionKey}`;
|
||||
let tracker = reactionTrackers.get(trackerKey);
|
||||
|
|
@ -263,7 +285,7 @@ export function createLifecycleManager(
|
|||
projectId: event.projectId,
|
||||
message: `Reaction '${reactionKey}' sent message to agent`,
|
||||
data: { reactionKey, attempts: tracker.attempts },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -274,13 +296,12 @@ export function createLifecycleManager(
|
|||
escalated: false,
|
||||
};
|
||||
} catch {
|
||||
// Send failed — escalate
|
||||
await notifyHuman(event, reactionConfig.priority ?? "warning");
|
||||
// Send failed — allow retry on next poll cycle (don't escalate immediately)
|
||||
return {
|
||||
reactionType: reactionKey,
|
||||
success: false,
|
||||
action: "send-to-agent",
|
||||
escalated: true,
|
||||
escalated: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -319,10 +340,7 @@ export function createLifecycleManager(
|
|||
}
|
||||
|
||||
/** Send a notification to all configured notifiers. */
|
||||
async function notifyHuman(
|
||||
event: OrchestratorEvent,
|
||||
priority: EventPriority
|
||||
): Promise<void> {
|
||||
async function notifyHuman(event: OrchestratorEvent, priority: EventPriority): Promise<void> {
|
||||
const eventWithPriority = { ...event, priority };
|
||||
const notifierNames = config.notificationRouting[priority] ?? config.defaults.notifiers;
|
||||
|
||||
|
|
@ -355,6 +373,15 @@ export function createLifecycleManager(
|
|||
allCompleteEmitted = false;
|
||||
}
|
||||
|
||||
// Clear reaction trackers for the old status so retries reset on state changes
|
||||
const oldEventType = statusToEventType(undefined, oldStatus);
|
||||
if (oldEventType) {
|
||||
const oldReactionKey = eventToReactionKey(oldEventType);
|
||||
if (oldReactionKey) {
|
||||
reactionTrackers.delete(`${session.id}:${oldReactionKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit transition event
|
||||
const eventType = statusToEventType(oldStatus, newStatus);
|
||||
if (eventType) {
|
||||
|
|
@ -373,16 +400,10 @@ export function createLifecycleManager(
|
|||
if (reactionKey) {
|
||||
// Check project-specific overrides first, then global
|
||||
const project = config.projects[session.projectId];
|
||||
const reactionConfig =
|
||||
project?.reactions?.[reactionKey] ??
|
||||
config.reactions[reactionKey];
|
||||
const reactionConfig = project?.reactions?.[reactionKey] ?? config.reactions[reactionKey];
|
||||
|
||||
if (reactionConfig && reactionConfig.auto !== false && reactionConfig.action) {
|
||||
await executeReaction(
|
||||
event,
|
||||
reactionKey,
|
||||
reactionConfig as ReactionConfig
|
||||
);
|
||||
await executeReaction(event, reactionKey, reactionConfig as ReactionConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -401,16 +422,20 @@ export function createLifecycleManager(
|
|||
try {
|
||||
const sessions = await sessionManager.list();
|
||||
|
||||
const activeSessions = sessions.filter(
|
||||
(s) => s.status !== "merged" && s.status !== "killed"
|
||||
);
|
||||
// Include sessions that are active OR whose status changed from what we last saw
|
||||
// (e.g., list() detected a dead runtime and marked it "killed" — we need to
|
||||
// process that transition even though the new status is terminal)
|
||||
const sessionsToCheck = sessions.filter((s) => {
|
||||
if (s.status !== "merged" && s.status !== "killed") return true;
|
||||
const tracked = states.get(s.id);
|
||||
return tracked !== undefined && tracked !== s.status;
|
||||
});
|
||||
|
||||
// Poll all active sessions concurrently
|
||||
await Promise.allSettled(
|
||||
activeSessions.map((s) => checkSession(s))
|
||||
);
|
||||
// Poll all sessions concurrently
|
||||
await Promise.allSettled(sessionsToCheck.map((s) => checkSession(s)));
|
||||
|
||||
// Check if all sessions are complete (emit only once)
|
||||
const activeSessions = sessions.filter((s) => s.status !== "merged" && s.status !== "killed");
|
||||
if (sessions.length > 0 && activeSessions.length === 0 && !allCompleteEmitted) {
|
||||
allCompleteEmitted = true;
|
||||
const event = createEvent("summary.all_complete", {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,14 @@
|
|||
* issue=https://linear.app/team/issue/INT-1234
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "node:fs";
|
||||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
unlinkSync,
|
||||
readdirSync,
|
||||
} from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import type { SessionId, SessionMetadata } from "./types.js";
|
||||
|
||||
|
|
@ -37,10 +44,12 @@ function parseMetadataFile(content: string): Record<string, string> {
|
|||
|
||||
/** Serialize a record back to key=value format. */
|
||||
function serializeMetadata(data: Record<string, string>): string {
|
||||
return Object.entries(data)
|
||||
return (
|
||||
Object.entries(data)
|
||||
.filter(([, v]) => v !== undefined && v !== "")
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n") + "\n";
|
||||
.join("\n") + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate sessionId to prevent path traversal. */
|
||||
|
|
@ -61,10 +70,7 @@ function metadataPath(dataDir: string, sessionId: SessionId): string {
|
|||
/**
|
||||
* Read metadata for a session. Returns null if the file doesn't exist.
|
||||
*/
|
||||
export function readMetadata(
|
||||
dataDir: string,
|
||||
sessionId: SessionId
|
||||
): SessionMetadata | null {
|
||||
export function readMetadata(dataDir: string, sessionId: SessionId): SessionMetadata | null {
|
||||
const path = metadataPath(dataDir, sessionId);
|
||||
if (!existsSync(path)) return null;
|
||||
|
||||
|
|
@ -89,7 +95,7 @@ export function readMetadata(
|
|||
*/
|
||||
export function readMetadataRaw(
|
||||
dataDir: string,
|
||||
sessionId: SessionId
|
||||
sessionId: SessionId,
|
||||
): Record<string, string> | null {
|
||||
const path = metadataPath(dataDir, sessionId);
|
||||
if (!existsSync(path)) return null;
|
||||
|
|
@ -102,7 +108,7 @@ export function readMetadataRaw(
|
|||
export function writeMetadata(
|
||||
dataDir: string,
|
||||
sessionId: SessionId,
|
||||
metadata: SessionMetadata
|
||||
metadata: SessionMetadata,
|
||||
): void {
|
||||
const path = metadataPath(dataDir, sessionId);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
|
|
@ -130,7 +136,7 @@ export function writeMetadata(
|
|||
export function updateMetadata(
|
||||
dataDir: string,
|
||||
sessionId: SessionId,
|
||||
updates: Partial<Record<string, string>>
|
||||
updates: Partial<Record<string, string>>,
|
||||
): void {
|
||||
const path = metadataPath(dataDir, sessionId);
|
||||
let existing: Record<string, string> = {};
|
||||
|
|
@ -139,11 +145,12 @@ export function updateMetadata(
|
|||
existing = parseMetadataFile(readFileSync(path, "utf-8"));
|
||||
}
|
||||
|
||||
// Merge updates — delete keys set to empty string
|
||||
// Merge updates — remove keys set to empty string
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value === undefined) continue;
|
||||
if (value === "") {
|
||||
delete existing[key];
|
||||
const { [key]: _, ...rest } = existing;
|
||||
existing = rest;
|
||||
} else {
|
||||
existing[key] = value;
|
||||
}
|
||||
|
|
@ -157,11 +164,7 @@ export function updateMetadata(
|
|||
* Delete a session's metadata file.
|
||||
* Optionally archive it to an `archive/` subdirectory.
|
||||
*/
|
||||
export function deleteMetadata(
|
||||
dataDir: string,
|
||||
sessionId: SessionId,
|
||||
archive = true
|
||||
): void {
|
||||
export function deleteMetadata(dataDir: string, sessionId: SessionId, archive = true): void {
|
||||
const path = metadataPath(dataDir, sessionId);
|
||||
if (!existsSync(path)) return;
|
||||
|
||||
|
|
@ -183,7 +186,5 @@ export function listMetadata(dataDir: string): SessionId[] {
|
|||
const dir = join(dataDir, "sessions");
|
||||
if (!existsSync(dir)) return [];
|
||||
|
||||
return readdirSync(dir).filter(
|
||||
(name) => name !== "archive" && !name.startsWith(".")
|
||||
);
|
||||
return readdirSync(dir).filter((name) => name !== "archive" && !name.startsWith("."));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,12 +29,7 @@ import type {
|
|||
PluginRegistry,
|
||||
RuntimeHandle,
|
||||
} from "./types.js";
|
||||
import {
|
||||
readMetadataRaw,
|
||||
writeMetadata,
|
||||
deleteMetadata,
|
||||
listMetadata,
|
||||
} from "./metadata.js";
|
||||
import { readMetadataRaw, writeMetadata, deleteMetadata, listMetadata } from "./metadata.js";
|
||||
import { createEvent } from "./event-bus.js";
|
||||
|
||||
/** Escape regex metacharacters in a string. */
|
||||
|
|
@ -43,10 +38,7 @@ function escapeRegex(str: string): string {
|
|||
}
|
||||
|
||||
/** Get the next session number for a project. */
|
||||
function getNextSessionNumber(
|
||||
existingSessions: string[],
|
||||
prefix: string
|
||||
): number {
|
||||
function getNextSessionNumber(existingSessions: string[], prefix: string): number {
|
||||
let max = 0;
|
||||
const pattern = new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`);
|
||||
for (const name of existingSessions) {
|
||||
|
|
@ -70,9 +62,19 @@ function safeJsonParse<T>(str: string): T | null {
|
|||
|
||||
/** Valid session statuses for validation. */
|
||||
const VALID_STATUSES: ReadonlySet<string> = new Set([
|
||||
"spawning", "working", "pr_open", "ci_failed", "review_pending",
|
||||
"changes_requested", "approved", "mergeable", "merged",
|
||||
"needs_input", "stuck", "errored", "killed",
|
||||
"spawning",
|
||||
"working",
|
||||
"pr_open",
|
||||
"ci_failed",
|
||||
"review_pending",
|
||||
"changes_requested",
|
||||
"approved",
|
||||
"mergeable",
|
||||
"merged",
|
||||
"needs_input",
|
||||
"stuck",
|
||||
"errored",
|
||||
"killed",
|
||||
]);
|
||||
|
||||
/** Validate and normalize a status string. */
|
||||
|
|
@ -82,10 +84,7 @@ function validateStatus(raw: string | undefined): SessionStatus {
|
|||
}
|
||||
|
||||
/** Reconstruct a Session object from raw metadata key=value pairs. */
|
||||
function metadataToSession(
|
||||
sessionId: SessionId,
|
||||
meta: Record<string, string>
|
||||
): Session {
|
||||
function metadataToSession(sessionId: SessionId, meta: Record<string, string>): Session {
|
||||
return {
|
||||
id: sessionId,
|
||||
projectId: meta["project"] ?? "",
|
||||
|
|
@ -109,9 +108,7 @@ function metadataToSession(
|
|||
runtimeHandle: meta["runtimeHandle"]
|
||||
? safeJsonParse<RuntimeHandle>(meta["runtimeHandle"])
|
||||
: null,
|
||||
agentInfo: meta["summary"]
|
||||
? { summary: meta["summary"], agentSessionId: null }
|
||||
: null,
|
||||
agentInfo: meta["summary"] ? { summary: meta["summary"], agentSessionId: null } : null,
|
||||
createdAt: meta["createdAt"] ? new Date(meta["createdAt"]) : new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: meta,
|
||||
|
|
@ -130,24 +127,16 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
|
||||
/** Resolve which plugins to use for a project. */
|
||||
function resolvePlugins(project: ProjectConfig) {
|
||||
const runtime = registry.get<Runtime>(
|
||||
"runtime",
|
||||
project.runtime ?? config.defaults.runtime
|
||||
);
|
||||
const agent = registry.get<Agent>(
|
||||
"agent",
|
||||
project.agent ?? config.defaults.agent
|
||||
);
|
||||
const runtime = registry.get<Runtime>("runtime", project.runtime ?? config.defaults.runtime);
|
||||
const agent = registry.get<Agent>("agent", project.agent ?? config.defaults.agent);
|
||||
const workspace = registry.get<Workspace>(
|
||||
"workspace",
|
||||
project.workspace ?? config.defaults.workspace
|
||||
project.workspace ?? config.defaults.workspace,
|
||||
);
|
||||
const tracker = project.tracker
|
||||
? registry.get<Tracker>("tracker", project.tracker.plugin)
|
||||
: null;
|
||||
const scm = project.scm
|
||||
? registry.get<SCM>("scm", project.scm.plugin)
|
||||
: null;
|
||||
const scm = project.scm ? registry.get<SCM>("scm", project.scm.plugin) : null;
|
||||
|
||||
return { runtime, agent, workspace, tracker, scm };
|
||||
}
|
||||
|
|
@ -229,23 +218,16 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
} catch (err) {
|
||||
// Clean up workspace if runtime creation failed
|
||||
if (plugins.workspace && workspacePath !== project.path) {
|
||||
try { await plugins.workspace.destroy(workspacePath); } catch { /* best effort */ }
|
||||
try {
|
||||
await plugins.workspace.destroy(workspacePath);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
writeMetadata(config.dataDir, sessionId, {
|
||||
worktree: workspacePath,
|
||||
branch,
|
||||
status: "spawning",
|
||||
issue: spawnConfig.issueId,
|
||||
project: spawnConfig.projectId,
|
||||
createdAt: new Date().toISOString(),
|
||||
runtimeHandle: JSON.stringify(handle),
|
||||
});
|
||||
|
||||
// Post-launch setup (if agent supports it)
|
||||
// Write metadata and run post-launch setup — clean up on failure
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
projectId: spawnConfig.projectId,
|
||||
|
|
@ -262,9 +244,41 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
metadata: {},
|
||||
};
|
||||
|
||||
try {
|
||||
writeMetadata(config.dataDir, sessionId, {
|
||||
worktree: workspacePath,
|
||||
branch,
|
||||
status: "spawning",
|
||||
issue: spawnConfig.issueId,
|
||||
project: spawnConfig.projectId,
|
||||
createdAt: new Date().toISOString(),
|
||||
runtimeHandle: JSON.stringify(handle),
|
||||
});
|
||||
|
||||
if (plugins.agent.postLaunchSetup) {
|
||||
await plugins.agent.postLaunchSetup(session);
|
||||
}
|
||||
} catch (err) {
|
||||
// Clean up runtime and workspace on post-launch failure
|
||||
try {
|
||||
await plugins.runtime.destroy(handle);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
if (plugins.workspace && workspacePath !== project.path) {
|
||||
try {
|
||||
await plugins.workspace.destroy(workspacePath);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
try {
|
||||
deleteMetadata(config.dataDir, sessionId, false);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Emit event
|
||||
eventBus.emit(
|
||||
|
|
@ -273,7 +287,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
projectId: spawnConfig.projectId,
|
||||
message: `Session ${sessionId} spawned${spawnConfig.issueId ? ` for ${spawnConfig.issueId}` : ""}`,
|
||||
data: { branch, workspacePath, issueId: spawnConfig.issueId },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return session;
|
||||
|
|
@ -330,14 +344,16 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
const projectId = raw["project"] ?? "";
|
||||
const project = config.projects[projectId];
|
||||
|
||||
// Destroy runtime
|
||||
if (raw["runtimeHandle"] && project) {
|
||||
const plugins = resolvePlugins(project);
|
||||
if (plugins.runtime) {
|
||||
// Destroy runtime (attempt even without project config using default runtime)
|
||||
if (raw["runtimeHandle"]) {
|
||||
const handle = safeJsonParse<RuntimeHandle>(raw["runtimeHandle"]);
|
||||
if (handle) {
|
||||
const runtimePlugin = project
|
||||
? resolvePlugins(project).runtime
|
||||
: registry.get<Runtime>("runtime", config.defaults.runtime);
|
||||
if (runtimePlugin) {
|
||||
try {
|
||||
await plugins.runtime.destroy(handle);
|
||||
await runtimePlugin.destroy(handle);
|
||||
} catch {
|
||||
// Runtime might already be gone
|
||||
}
|
||||
|
|
@ -345,12 +361,14 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
}
|
||||
}
|
||||
|
||||
// Destroy workspace
|
||||
if (raw["worktree"] && project) {
|
||||
const plugins = resolvePlugins(project);
|
||||
if (plugins.workspace) {
|
||||
// Destroy workspace (attempt even without project config using default workspace)
|
||||
if (raw["worktree"]) {
|
||||
const workspacePlugin = project
|
||||
? resolvePlugins(project).workspace
|
||||
: registry.get<Workspace>("workspace", config.defaults.workspace);
|
||||
if (workspacePlugin) {
|
||||
try {
|
||||
await plugins.workspace.destroy(raw["worktree"]);
|
||||
await workspacePlugin.destroy(raw["worktree"]);
|
||||
} catch {
|
||||
// Workspace might already be gone
|
||||
}
|
||||
|
|
@ -366,7 +384,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
sessionId,
|
||||
projectId,
|
||||
message: `Session ${sessionId} killed`,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -400,10 +418,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
// Check if issue is completed
|
||||
if (!shouldKill && session.issueId && plugins.tracker) {
|
||||
try {
|
||||
const completed = await plugins.tracker.isCompleted(
|
||||
session.issueId,
|
||||
project
|
||||
);
|
||||
const completed = await plugins.tracker.isCompleted(session.issueId, project);
|
||||
if (completed) shouldKill = true;
|
||||
} catch {
|
||||
// Can't check issue — skip
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export async function listSessions(): Promise<TmuxSessionInfo[]> {
|
|||
const output = await tmux(
|
||||
"list-sessions",
|
||||
"-F",
|
||||
"#{session_name}\t#{session_created_string}\t#{session_attached}\t#{session_windows}"
|
||||
"#{session_name}\t#{session_created_string}\t#{session_attached}\t#{session_windows}",
|
||||
);
|
||||
|
||||
return output
|
||||
|
|
@ -125,7 +125,7 @@ export async function newSession(opts: NewSessionOptions): Promise<void> {
|
|||
export async function sendKeys(
|
||||
sessionName: string,
|
||||
text: string,
|
||||
pressEnter = true
|
||||
pressEnter = true,
|
||||
): Promise<void> {
|
||||
// Clear any partial input first (matches bash reference scripts)
|
||||
await tmux("send-keys", "-t", sessionName, "Escape");
|
||||
|
|
@ -145,7 +145,11 @@ export async function sendKeys(
|
|||
await tmux("load-buffer", "-b", bufferName, tmpFile);
|
||||
await tmux("paste-buffer", "-b", bufferName, "-d", "-t", sessionName);
|
||||
} finally {
|
||||
try { unlinkSync(tmpFile); } catch { /* ignore cleanup errors */ }
|
||||
try {
|
||||
unlinkSync(tmpFile);
|
||||
} catch {
|
||||
/* ignore cleanup errors */
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await tmux("send-keys", "-t", sessionName, text);
|
||||
|
|
@ -166,18 +170,8 @@ export async function sendKeys(
|
|||
* @param sessionName - tmux session name
|
||||
* @param lines - Number of scrollback lines to capture (default 30)
|
||||
*/
|
||||
export async function capturePane(
|
||||
sessionName: string,
|
||||
lines = 30
|
||||
): Promise<string> {
|
||||
return tmux(
|
||||
"capture-pane",
|
||||
"-t",
|
||||
sessionName,
|
||||
"-p",
|
||||
"-S",
|
||||
`-${lines}`
|
||||
);
|
||||
export async function capturePane(sessionName: string, lines = 30): Promise<string> {
|
||||
return tmux("capture-pane", "-t", sessionName, "-p", "-S", `-${lines}`);
|
||||
}
|
||||
|
||||
/** Kill a tmux session. */
|
||||
|
|
|
|||
802
pnpm-lock.yaml
802
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue