refactor(controlplane): rename subagent ids

This commit is contained in:
Clawdie AI 2026-04-08 19:32:10 +00:00
parent f1a6ba7815
commit f14f8556ff
25 changed files with 208 additions and 208 deletions

View file

@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `doc/NAMING-HANDOFF.md` deleted — all checklist items complete (vitest 847/847 green).
### Renamed
- Control plane terminology sweep across docs: `CEO`/`'ceo'``orchestrator`, `company``system`, `Paperclip``Control Plane`, `board``operator`. Affects `SYSADMIN.md`, `DBA.md`, `GIT_ADMIN.md`, `doc/CONTROLPLANE-*.md`, `doc/MULTI-PROVIDER-ARCHITECTURE.md`, `docs/public/architecture/controlplane.md`. Code rename landed earlier in `061a25f`; this catches the doc tail. (`doc/NAMING-HANDOFF.md` remaining items: `CLAWDIE.md` creation + wiring `resolveIdentityFile()` — tracked for follow-up.)
- Control plane terminology sweep across docs: `CEO`/`'ceo'``orchestrator`, `company``system`, `Paperclip``Control Plane`, `board``operator`. Affects `SYSADMIN_AGENT.md`, `DB_ADMIN_AGENT.md`, `GIT_ADMIN_AGENT.md`, `doc/CONTROLPLANE-*.md`, `doc/MULTI-PROVIDER-ARCHITECTURE.md`, `docs/public/architecture/controlplane.md`. Code rename landed earlier in `061a25f`; this catches the doc tail. (`doc/NAMING-HANDOFF.md` remaining items: `CLAWDIE.md` creation + wiring `resolveIdentityFile()` — tracked for follow-up.)
### Added
- `setup/agent-cli-check.ts` — fail-fast gate at the top of `setup onboard` requiring at least one of `claude`, `codex`, `gemini`, `pi` on `PATH` (mirrors Paperclip's per-adapter `ensureCommandResolvable` pattern as a single check)
@ -53,7 +53,7 @@ Major architectural addition: Paperclip as multi-agent orchestration layer for C
- `doc/PAPERCLIP-INTEGRATION.md` — Full architecture, security model, deployment guide, operations, and troubleshooting for Paperclip control plane
- `doc/PAPERCLIP-COMPANY-STRUCTURE.md` — Default organization chart (CEO + Sysadmin + DBA + Git Admin), role definitions, skills mapping, decision logic, approval workflows
- `setup/paperclip.ts` — Jail provisioning for Paperclip at 10.0.0.2, company auto-provisioning, skills mounting
- Identity files: `SYSADMIN.md`, `DBA.md`, `GIT_ADMIN.md` — Agent role instructions with skill-aware decision logic (alongside existing `SOUL.md`)
- Identity files: `SYSADMIN_AGENT.md`, `DB_ADMIN_AGENT.md`, `GIT_ADMIN_AGENT.md` — Agent role instructions with skill-aware decision logic (alongside existing `SOUL.md`)
- Operational skills: `jail-status`, `backup-db`, `disk-usage`, `service-restart`, `system-stats`, `db-migrate`, `db-vacuum`, `db-analyze`, `git-pull`, `git-merge`, `git-release-tag`, `git-branch-protect`, `git-push-mirror`
- All 33 skills in `.agent/skills/` updated with `compatibility: FreeBSD 15.0+` marker in SKILL.md frontmatter
- PostgreSQL integration — Paperclip connects to existing PostgreSQL jail at 10.0.0.3, shares database instance with Clawdie

View file

@ -1,4 +1,4 @@
# DBA.md - Data Steward
# DB_ADMIN_AGENT.md - Data Steward
_Data is your responsibility. It's not just bytes — it's the system's memory, decisions, and trust._
@ -36,7 +36,7 @@ When orchestrator creates a task assigned to you, you:
### Layer 1: Control Plane (What's My Task?)
```
GET /api/controlplane/tasks?role=dba
GET /api/controlplane/tasks?role=db_admin_agent
→ [
{
task_id: "TASK-042",
@ -49,7 +49,7 @@ GET /api/controlplane/tasks?role=dba
### Layer 2: Clawdie (What Do I Know?)
```
Read data/sessions/dba.jsonl
Read data/sessions/db_admin_agent.jsonl
→ [
{"task": "Backup clawdie_ai_public", "skill": "backup-db", "runtime": "18m", "size": "2.3GB"},
{"task": "Analyze database", "skill": "db-analyze", "runtime": "12m"}
@ -157,7 +157,7 @@ Example:
POST /api/controlplane/activity
{
"event_type": "error",
"agent_id": "dba",
"agent_id": "db_admin_agent",
"operation": "db-vacuum on clawdie_ai_public",
"error": "Vacuum failed: lock timeout (another process holding lock for 45min)",
"action_taken": "Aborted vacuum, escalated to orchestrator",
@ -192,7 +192,7 @@ POST /api/controlplane/activity
## Continuity & Memory
Your session lives in `data/sessions/dba.jsonl`.
Your session lives in `data/sessions/db_admin_agent.jsonl`.
Each time you complete a job:
```json
@ -223,7 +223,7 @@ This context flows into your system prompt, making you more efficient over time.
- `doc/CONTROLPLANE-MESSAGE-CONTRACT.md` — how you query the control plane API, how you post results
- `doc/CONTROLPLANE-AGENT-ROLES.md` — your role in the org chart
- `SOUL.md` — orchestrator's identity (your boss)
- `SYSADMIN.md` — infrastructure guardian (coordinates with you on backups, migrations)
- `SYSADMIN_AGENT.md` — infrastructure guardian (coordinates with you on backups, migrations)
---

View file

@ -1,4 +1,4 @@
# GIT_ADMIN.md - Version Steward
# GIT_ADMIN_AGENT.md - Version Steward
_You own the history. Every commit is a decision. Every release is a promise._
@ -36,7 +36,7 @@ When orchestrator creates a task assigned to you, you:
### Layer 1: Control Plane (What's My Task?)
```
GET /api/controlplane/tasks?role=git_admin
GET /api/controlplane/tasks?role=git_admin_agent
→ [
{
task_id: "TASK-105",
@ -54,7 +54,7 @@ GET /api/controlplane/tasks?role=git_admin
### Layer 2: Clawdie (What Do I Know?)
```
Read /home/clawdie/clawdie-ai/data/sessions/git_admin.jsonl
Read /home/clawdie/clawdie-ai/data/sessions/git_admin_agent.jsonl
→ [
{"task": "Merge PR #40", "skill": "git-merge", "conflict": false, "runtime": "45s"},
{"task": "Tag v0.10.0", "skill": "git-release-tag", "runtime": "8s"}
@ -196,7 +196,7 @@ Example:
POST /api/controlplane/activity
{
"event_type": "approval_request",
"agent_id": "git_admin",
"agent_id": "git_admin_agent",
"operation": "Merge PR #42 (feature/paperclip-integration) into main",
"reason": "Merge conflict detected",
"conflict_analysis": {
@ -240,7 +240,7 @@ POST /api/controlplane/activity
## Continuity & Memory
Your session lives in `/home/clawdie/clawdie-ai/data/sessions/git_admin.jsonl`.
Your session lives in `/home/clawdie/clawdie-ai/data/sessions/git_admin_agent.jsonl`.
Each time you complete a job:
```json
@ -272,7 +272,7 @@ This context flows into your system prompt, making you faster and more confident
- `doc/CONTROLPLANE-MESSAGE-CONTRACT.md` — how you query the control plane API, how you post results
- `doc/CONTROLPLANE-AGENT-ROLES.md` — your role in the org chart
- `SOUL.md` — orchestrator's identity (your boss)
- `SYSADMIN.md` — infrastructure guardian (sometimes coordinates with you on releases)
- `SYSADMIN_AGENT.md` — infrastructure guardian (sometimes coordinates with you on releases)
- `CHANGELOG.md` — project changelog (you ensure it's updated before releases)
---

View file

@ -10,7 +10,7 @@
- **Control Plane:** Multi-agent orchestrator running inside clawdie service (port 3100)
- Roles: Orchestrator (80% budget), Sysadmin (10%, daily heartbeat), DBA (5%), Git Admin (5%)
- Main agent id = AGENT_NAME (e.g., `clawdie`), role = `orchestrator`
- Identity files: `{AGENT_NAME}.md` (falls back to `SOUL.md`), `SYSADMIN.md`, `DBA.md`, `GIT_ADMIN.md`
- Identity files: `{AGENT_NAME}.md` (falls back to `SOUL.md`), `SYSADMIN_AGENT.md`, `DB_ADMIN_AGENT.md`, `GIT_ADMIN_AGENT.md`
- 15 operational skills in `.agent/skills/`
- **Telegram bridge:** Routes messages to control plane agents via keyword matching
- **Dashboard feasibility:** Phases A & B green (Vite 8 + React 19 + Tailwind 4 + native Rust bindings all build on FreeBSD 15.0). Phases C & D pending.

View file

@ -1,4 +1,4 @@
# SYSADMIN.md - Infrastructure Guardian
# SYSADMIN_AGENT.md - Infrastructure Guardian
_You keep the machines running. You're methodical, preventive, and paranoid about breakage._
@ -49,7 +49,7 @@ Same pattern:
### Layer 1: Control Plane (What's My Job?)
```
GET /api/controlplane/tasks?role=sysadmin
GET /api/controlplane/tasks?role=sysadmin_agent
→ [
{ task_id: "TASK-001", title: "Check if db jail is running" },
{ task_id: "TASK-002", title: "Backup database to external drive" }
@ -58,7 +58,7 @@ GET /api/controlplane/tasks?role=sysadmin
### Layer 2: Clawdie (What Do I Know?)
```
Read data/sessions/sysadmin.jsonl
Read data/sessions/sysadmin_agent.jsonl
→ [
{"task": "Check db jail", "skill": "jail-status", "outcome": "running"},
{"task": "Backup database", "skill": "backup-db", "outcome": "2.3GB, success"}
@ -91,13 +91,13 @@ You have access to 14 operational skills. Here's how you match tasks:
| Task Pattern | Skill | Example Output |
|---|---|---|
| "Check if X jail is running" or "Is X up?" | `jail-status` | `sysadmin-db is running (uptime 5d 3h)` |
| "Check if X jail is running" or "Is X up?" | `jail-status` | `sysadmin_agent-db is running (uptime 5d 3h)` |
| "How much free disk?" or "Disk space?" | `disk-usage` | `/var/db 45% full, 2TB free` |
| "System health?" or "CPU/RAM/load?" | `system-stats` | `CPU 12%, RAM 4GB/8GB, load 0.3` |
| "Restart X service" or "Start/stop X" | `service-restart` | `nginx restarted successfully` |
| "Back up the database" | `backup-db` | `Backup complete: 2.3GB, 18m runtime` |
| "Free up space" or "Clean up logs" | `disk-cleanup` | `Removed 50GB old logs` |
| "Check RCTL/quotas" | `resource-limits` | `sysadmin-db: 2GB limit, 1.2GB used` |
| "Check RCTL/quotas" | `resource-limits` | `sysadmin_agent-db: 2GB limit, 1.2GB used` |
| "Create ZFS snapshot" | `zfs-snapshot` | `Snapshot created: tank/clawdie@2026-04-07` |
| "Take database backup to offsite" | `backup-offsite` | `Backup synced to Tailscale peer` |
| "PF firewall status?" | `pf-status` | `PF enabled, 3 rules loaded, 0 dropped` |
@ -143,7 +143,7 @@ Escalation format:
POST /api/controlplane/activity
{
"event_type": "approval_request",
"agent_id": "sysadmin",
"agent_id": "sysadmin_agent",
"operation": "Description of what I want to do",
"reasoning": "Why this doesn't match my patterns",
"estimated_tokens": 3500
@ -154,7 +154,7 @@ POST /api/controlplane/activity
## Memory & Continuity
Your session lives in `/home/clawdie/clawdie-ai/data/sessions/sysadmin.jsonl`.
Your session lives in `/home/clawdie/clawdie-ai/data/sessions/sysadmin_agent.jsonl`.
Each heartbeat, you append:
```json
@ -163,12 +163,12 @@ Each heartbeat, you append:
"task": "Check if db jail is running",
"skill": "jail-status",
"result": "success",
"output": "sysadmin-db running, uptime 5d 3h, CPU 2%, RAM 512/2048MB",
"output": "sysadmin_agent-db running, uptime 5d 3h, CPU 2%, RAM 512/2048MB",
"tokens_used": 420
}
```
Next heartbeat, you read this file. If you see "sysadmin-db was running yesterday at 10:30," you know:
Next heartbeat, you read this file. If you see "sysadmin_agent-db was running yesterday at 10:30," you know:
- "It's been stable for 5+ days"
- "When I ran jail-status before, it took 420 tokens"
- "Same skill works for this task"

View file

@ -40,7 +40,7 @@
## Sysadmin — Systems Administrator
**Identity:** `SYSADMIN.md`
**Identity:** `SYSADMIN_AGENT.md`
**Adapter:** `pi-local`, model: `anthropic/claude-3-5-sonnet`
- Monitor jail health, manage services, handle incidents
@ -63,7 +63,7 @@
## DBA — Database Administrator
**Identity:** `DBA.md`
**Identity:** `DB_ADMIN_AGENT.md`
**Adapter:** `pi-local`, model: `anthropic/claude-3-5-sonnet`
- PostgreSQL operations: migrations, backups, performance tuning
@ -85,7 +85,7 @@
## Git Admin — Git Administrator
**Identity:** `GIT_ADMIN.md`
**Identity:** `GIT_ADMIN_AGENT.md`
**Adapter:** `pi-local`, model: `anthropic/claude-3-5-sonnet`
- Manage repositories, branches, releases, merges
@ -121,4 +121,4 @@ Budget limit reached → Control plane pauses work → Operator must approve inc
- `doc/CONTROLPLANE-ARCHITECTURE.md` — service architecture
- `doc/CONTROLPLANE-MESSAGE-CONTRACT.md` — API contracts
- `SOUL.md`, `SYSADMIN.md`, `DBA.md`, `GIT_ADMIN.md` — agent identities
- `SOUL.md`, `SYSADMIN_AGENT.md`, `DB_ADMIN_AGENT.md`, `GIT_ADMIN_AGENT.md` — agent identities

View file

@ -9,7 +9,7 @@ Handoff for next agent. Read listed files before touching code.
- `doc/CONTROLPLANE-ARCHITECTURE.md` — unified service architecture (single clawdie service, not a separate jail)
- `doc/CONTROLPLANE-AGENT-ROLES.md` — agent roles, budgets, heartbeat policy, skill mapping
- `doc/CONTROLPLANE-MESSAGE-CONTRACT.md` — API contracts between control plane (governance) and agents (operations)
- `SOUL.md`, `SYSADMIN.md`, `DBA.md`, `GIT_ADMIN.md` — agent identity files with dual-layer decision logic
- `SOUL.md`, `SYSADMIN_AGENT.md`, `DB_ADMIN_AGENT.md`, `GIT_ADMIN_AGENT.md` — agent identity files with dual-layer decision logic
**Architecture:** Control plane concepts (agents, tasks, budgets, approvals, activity log) are integrated INTO the existing `clawdie` service on the host. Single unified scheduler (30s ticks), shared PostgreSQL at `10.0.0.3`, agents run via `pi` CLI.
@ -59,7 +59,7 @@ src/controlplane.test.ts — full heartbeat cycle end-to-end
```sql
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
role TEXT NOT NULL CHECK (role IN ('orchestrator','sysadmin','dba','git_admin')),
role TEXT NOT NULL CHECK (role IN ('orchestrator','sysadmin_agent','db_admin_agent','git_admin_agent')),
adapter TEXT NOT NULL DEFAULT 'pi-local',
heartbeat_enabled BOOLEAN DEFAULT FALSE,
heartbeat_interval_sec INTEGER,

View file

@ -29,16 +29,16 @@ Authorization: Bearer {agent_api_key}
{
"agents": [
{ "id": "clawdie", "role": "orchestrator", "heartbeat_enabled": false },
{ "id": "sysadmin", "role": "sysadmin", "heartbeat_enabled": true },
{ "id": "dba", "role": "dba", "heartbeat_enabled": false },
{ "id": "git_admin", "role": "git_admin", "heartbeat_enabled": false }
{ "id": "sysadmin_agent", "role": "sysadmin_agent", "heartbeat_enabled": true },
{ "id": "db_admin_agent", "role": "db_admin_agent", "heartbeat_enabled": false },
{ "id": "git_admin_agent", "role": "git_admin_agent", "heartbeat_enabled": false }
],
"budget": {
"daily_tokens": 100000,
"spent_today": 25000,
"remaining": 75000,
"hard_limit_exceeded": false,
"allocation": { "orchestrator": 80000, "sysadmin": 10000, "dba": 5000, "git_admin": 5000 }
"allocation": { "orchestrator": 80000, "sysadmin_agent": 10000, "db_admin_agent": 5000, "git_admin_agent": 5000 }
}
}
```
@ -59,12 +59,12 @@ Authorization: Bearer {agent_api_key}
{
"task_id": "TASK-001",
"title": "Check if db jail is running",
"description": "Verify sysadmin-db is up and healthy",
"assigned_to": "sysadmin",
"description": "Verify clawdie-db is up and healthy",
"assigned_to": "sysadmin_agent",
"priority": "medium",
"status": "pending",
"created_at": "2026-04-07T10:30:00Z",
"context": { "jail_name": "sysadmin-db" }
"context": { "jail_name": "clawdie-db" }
}
]
}
@ -115,11 +115,11 @@ Authorization: Bearer {agent_api_key}
{
"event_type": "task_completed",
"task_id": "TASK-001",
"agent_id": "sysadmin",
"agent_id": "sysadmin_agent",
"skill_executed": "jail-status",
"result": {
"status": "success",
"output": "Jail sysadmin-db running, uptime 5d 3h, CPU 2.1%",
"output": "Jail clawdie-db running, uptime 5d 3h, CPU 2.1%",
"tokens_used": 420
}
}
@ -133,7 +133,7 @@ Authorization: Bearer {agent_api_key}
{
"event_type": "approval_request",
"agent_id": "git_admin",
"agent_id": "git_admin_agent",
"operation": "Merge PR #42 with conflict resolution",
"reasoning": "Conflict detected in src/index.ts",
"estimated_tokens": 8500
@ -148,7 +148,7 @@ Authorization: Bearer {agent_api_key}
{
"event_type": "error",
"agent_id": "dba",
"agent_id": "db_admin_agent",
"error_message": "Vacuum failed: database locked",
"action_taken": "Escalated to orchestrator",
"tokens_used": 1200
@ -226,4 +226,4 @@ const agentEnv = {
- `doc/CONTROLPLANE-ARCHITECTURE.md` — service architecture
- `doc/CONTROLPLANE-AGENT-ROLES.md` — role definitions
- `SOUL.md`, `SYSADMIN.md`, `DBA.md`, `GIT_ADMIN.md` — agent identities
- `SOUL.md`, `SYSADMIN_AGENT.md`, `DB_ADMIN_AGENT.md`, `GIT_ADMIN_AGENT.md` — agent identities

View file

@ -26,7 +26,7 @@ Sam picked option 3 (submodule) over option 1 (vendor copy) and option 2 (stub).
`npm run dashboard` (or equivalent) on a fresh clawdie-ai checkout serves a working dashboard at `localhost:3100/dashboard` (or wherever we mount it on the existing Express server) showing:
1. Agent list (orchestrator, sysadmin, dba, git_admin) with heartbeat status
1. Agent list (orchestrator, sysadmin_agent, db_admin_agent, git_admin_agent) with heartbeat status
2. Task queue (pulled from `/api/controlplane/state`)
3. Approvals pane (pulled from `/api/controlplane/approvals`)
4. Activity log (pulled from `/api/controlplane/activity`)

View file

@ -208,7 +208,7 @@ Maps task context to the best provider:
```typescript
interface TaskProfile {
agentRole: string; // orchestrator, sysadmin, dba, git_admin
agentRole: string; // orchestrator, sysadmin_agent, db_admin_agent, git_admin_agent
skillMatched: boolean; // skill catalog matched exactly
complexity: 'routine' | 'moderate' | 'complex';
requiresReasoning: boolean;

View file

@ -34,7 +34,7 @@ independently.
**Chat:** `cognitivecomputations/dolphin3.0-phi4-mini-GGUF`
- `dolphin3.0-phi4-mini-Q4_K_M.gguf` — ~2.4 GB
- Phi-4 Mini base (Microsoft), Dolphin uncensored fine-tune
- Good for: routine sysadmin tasks, heartbeat interpretation, pi TUI
- Good for: routine sysadmin_agent tasks, heartbeat interpretation, pi TUI
- Weak at: complex multi-step tool chains
```sh

View file

@ -197,7 +197,7 @@ export function cleanupOrphans() { ... }
| `ollama` | Local LLM, runs on host (great upstream candidate) |
| `postgres-memory` | Supabase memory backend (great upstream candidate) |
| `telegram-admin` | Telegram bot management |
| `freebsd-admin` | Host sysadmin tasks |
| `freebsd-admin` | Host sysadmin_agent tasks |
| `sanoid` | ZFS snapshots — now must cover `/home/clawdie` too |
### Skills to PORT FROM NanoClaw (we're missing)

View file

@ -34,7 +34,7 @@ Single clawdie service (host):
```
Agents run on the host via the `pi` CLI. Each agent gets:
- A system prompt from their identity file (`SYSADMIN.md`, `DBA.md`, etc.)
- A system prompt from their identity file (`SYSADMIN_AGENT.md`, `DB_ADMIN_AGENT.md`, etc.)
- A persistent session in `data/sessions/{agent}.jsonl`
- Access to the skills catalog in `data/skills/`
- `CONTROLPLANE_*` env vars pointing at the local HTTP API
@ -131,4 +131,4 @@ npm run setup -- --step controlplane
- `doc/CONTROLPLANE-ARCHITECTURE.md` — detailed service layout
- `doc/CONTROLPLANE-MESSAGE-CONTRACT.md` — API contracts (what agents query and post)
- `doc/CONTROLPLANE-AGENT-ROLES.md` — role definitions, skill mappings, budgets
- `SOUL.md`, `SYSADMIN.md`, `DBA.md`, `GIT_ADMIN.md` — agent identity files
- `SOUL.md`, `SYSADMIN_AGENT.md`, `DB_ADMIN_AGENT.md`, `GIT_ADMIN_AGENT.md` — agent identity files

View file

@ -44,21 +44,21 @@ function makeEntry(overrides: Partial<SessionEntry> = {}): SessionEntry {
describe('Agent Session Persistence', () => {
describe('writing session entries', () => {
it('creates JSONL file at sessionDir/{agentName}.jsonl', () => {
writeSessionEntry(tmpDir, 'sysadmin', makeEntry(), WORKSPACE);
expect(fs.existsSync(path.join(tmpDir, 'sysadmin.jsonl'))).toBe(true);
writeSessionEntry(tmpDir, 'sysadmin_agent', makeEntry(), WORKSPACE);
expect(fs.existsSync(path.join(tmpDir, 'sysadmin_agent.jsonl'))).toBe(true);
});
it('appends entries, one JSON object per line', () => {
writeSessionEntry(tmpDir, 'sysadmin', makeEntry(), WORKSPACE);
writeSessionEntry(tmpDir, 'sysadmin', makeEntry({ task: 'Check disk' }), WORKSPACE);
const content = fs.readFileSync(path.join(tmpDir, 'sysadmin.jsonl'), 'utf-8');
writeSessionEntry(tmpDir, 'sysadmin_agent', makeEntry(), WORKSPACE);
writeSessionEntry(tmpDir, 'sysadmin_agent', makeEntry({ task: 'Check disk' }), WORKSPACE);
const content = fs.readFileSync(path.join(tmpDir, 'sysadmin_agent.jsonl'), 'utf-8');
const lines = content.trim().split('\n');
expect(lines).toHaveLength(2);
});
it('each entry includes timestamp, task, skill, result, tokens_used', () => {
writeSessionEntry(tmpDir, 'sysadmin', makeEntry(), WORKSPACE);
const session = loadSession(tmpDir, 'sysadmin', WORKSPACE);
writeSessionEntry(tmpDir, 'sysadmin_agent', makeEntry(), WORKSPACE);
const session = loadSession(tmpDir, 'sysadmin_agent', WORKSPACE);
const entry = session.entries[0];
expect(entry).toHaveProperty('timestamp');
expect(entry).toHaveProperty('task');
@ -69,9 +69,9 @@ describe('Agent Session Persistence', () => {
it('multiple entries are each on their own line (valid JSONL)', () => {
for (let i = 0; i < 5; i++) {
writeSessionEntry(tmpDir, 'sysadmin', makeEntry({ task: `Task ${i}` }), WORKSPACE);
writeSessionEntry(tmpDir, 'sysadmin_agent', makeEntry({ task: `Task ${i}` }), WORKSPACE);
}
const content = fs.readFileSync(path.join(tmpDir, 'sysadmin.jsonl'), 'utf-8');
const content = fs.readFileSync(path.join(tmpDir, 'sysadmin_agent.jsonl'), 'utf-8');
for (const line of content.trim().split('\n')) {
expect(() => JSON.parse(line)).not.toThrow();
}
@ -80,14 +80,14 @@ describe('Agent Session Persistence', () => {
describe('loading sessions', () => {
it('loads all entries from existing JSONL file', () => {
writeSessionEntry(tmpDir, 'dba', makeEntry(), WORKSPACE);
writeSessionEntry(tmpDir, 'dba', makeEntry({ task: 'Vacuum' }), WORKSPACE);
const session = loadSession(tmpDir, 'dba', WORKSPACE);
writeSessionEntry(tmpDir, 'db_admin_agent', makeEntry(), WORKSPACE);
writeSessionEntry(tmpDir, 'db_admin_agent', makeEntry({ task: 'Vacuum' }), WORKSPACE);
const session = loadSession(tmpDir, 'db_admin_agent', WORKSPACE);
expect(session.entries).toHaveLength(2);
});
it('returns empty entries array when file does not exist', () => {
const session = loadSession(tmpDir, 'git_admin', WORKSPACE);
const session = loadSession(tmpDir, 'git_admin_agent', WORKSPACE);
expect(session.entries).toHaveLength(0);
});
@ -98,9 +98,9 @@ describe('Agent Session Persistence', () => {
});
it('entries are in chronological order (oldest first)', () => {
writeSessionEntry(tmpDir, 'sysadmin', makeEntry({ task: 'First', timestamp: '2026-04-07T09:00:00Z' }), WORKSPACE);
writeSessionEntry(tmpDir, 'sysadmin', makeEntry({ task: 'Second', timestamp: '2026-04-07T10:00:00Z' }), WORKSPACE);
const session = loadSession(tmpDir, 'sysadmin', WORKSPACE);
writeSessionEntry(tmpDir, 'sysadmin_agent', makeEntry({ task: 'First', timestamp: '2026-04-07T09:00:00Z' }), WORKSPACE);
writeSessionEntry(tmpDir, 'sysadmin_agent', makeEntry({ task: 'Second', timestamp: '2026-04-07T10:00:00Z' }), WORKSPACE);
const session = loadSession(tmpDir, 'sysadmin_agent', WORKSPACE);
expect(session.entries[0].task).toBe('First');
expect(session.entries[1].task).toBe('Second');
});
@ -108,19 +108,19 @@ describe('Agent Session Persistence', () => {
describe('resilience', () => {
it('skips malformed lines (not crashing on bad JSON)', () => {
fs.writeFileSync(path.join(tmpDir, 'sysadmin.jsonl'), 'not-json\n', 'utf-8');
writeSessionEntry(tmpDir, 'sysadmin', makeEntry(), WORKSPACE);
const session = loadSession(tmpDir, 'sysadmin', WORKSPACE);
fs.writeFileSync(path.join(tmpDir, 'sysadmin_agent.jsonl'), 'not-json\n', 'utf-8');
writeSessionEntry(tmpDir, 'sysadmin_agent', makeEntry(), WORKSPACE);
const session = loadSession(tmpDir, 'sysadmin_agent', WORKSPACE);
expect(session.entries).toHaveLength(1);
});
it('still loads valid lines when one line is corrupt', () => {
const filePath = path.join(tmpDir, 'dba.jsonl');
const filePath = path.join(tmpDir, 'db_admin_agent.jsonl');
fs.writeFileSync(filePath, '', 'utf-8');
writeSessionEntry(tmpDir, 'dba', makeEntry({ task: 'Valid' }), WORKSPACE);
writeSessionEntry(tmpDir, 'db_admin_agent', makeEntry({ task: 'Valid' }), WORKSPACE);
fs.appendFileSync(filePath, 'bad{json\n');
writeSessionEntry(tmpDir, 'dba', makeEntry({ task: 'Also valid' }), WORKSPACE);
const session = loadSession(tmpDir, 'dba', WORKSPACE);
writeSessionEntry(tmpDir, 'db_admin_agent', makeEntry({ task: 'Also valid' }), WORKSPACE);
const session = loadSession(tmpDir, 'db_admin_agent', WORKSPACE);
expect(session.entries).toHaveLength(2);
expect(session.entries.every((e) => e.task.startsWith('Valid') || e.task === 'Also valid')).toBe(true);
});
@ -140,25 +140,25 @@ describe('Agent Session Persistence', () => {
describe('session path safety', () => {
it('session file path is under project root (workspace rule)', () => {
const filePath = resolveSessionPath('data/sessions', 'sysadmin', WORKSPACE);
const filePath = resolveSessionPath('data/sessions', 'sysadmin_agent', WORKSPACE);
expect(filePath).toMatch(new RegExp(`^${WORKSPACE}`));
});
it('rejects sessionDir pointing to /tmp/', () => {
expect(() => resolveSessionPath('/tmp', 'sysadmin', WORKSPACE)).toThrow();
expect(() => resolveSessionPath('/tmp', 'sysadmin_agent', WORKSPACE)).toThrow();
});
it('rejects sessionDir with path traversal (../)', () => {
expect(() => resolveSessionPath('../../../etc', 'sysadmin', WORKSPACE)).toThrow();
expect(() => resolveSessionPath('../../../etc', 'sysadmin_agent', WORKSPACE)).toThrow();
});
});
describe('pruning', () => {
it('prune keeps newest N entries', () => {
for (let i = 0; i < 10; i++) {
writeSessionEntry(tmpDir, 'sysadmin', makeEntry({ task: `Task ${i}` }), WORKSPACE);
writeSessionEntry(tmpDir, 'sysadmin_agent', makeEntry({ task: `Task ${i}` }), WORKSPACE);
}
const session = loadSession(tmpDir, 'sysadmin', WORKSPACE);
const session = loadSession(tmpDir, 'sysadmin_agent', WORKSPACE);
const pruned = pruneOldEntries(session, 3);
expect(pruned.entries).toHaveLength(3);
expect(pruned.entries[0].task).toBe('Task 7');
@ -166,8 +166,8 @@ describe('Agent Session Persistence', () => {
});
it('prune does not modify file when entries <= maxEntries', () => {
writeSessionEntry(tmpDir, 'dba', makeEntry(), WORKSPACE);
const session = loadSession(tmpDir, 'dba', WORKSPACE);
writeSessionEntry(tmpDir, 'db_admin_agent', makeEntry(), WORKSPACE);
const session = loadSession(tmpDir, 'db_admin_agent', WORKSPACE);
const before = fs.readFileSync(session.filePath, 'utf-8');
pruneOldEntries(session, 10);
const after = fs.readFileSync(session.filePath, 'utf-8');
@ -187,22 +187,22 @@ describe('Agent Session Persistence', () => {
describe('continuity context', () => {
it('last N entries formatted for system prompt injection', () => {
writeSessionEntry(tmpDir, 'sysadmin', makeEntry({ task: 'Check jails', skill: 'jail-status', result: 'success', tokens_used: 420 }), WORKSPACE);
const session = loadSession(tmpDir, 'sysadmin', WORKSPACE);
writeSessionEntry(tmpDir, 'sysadmin_agent', makeEntry({ task: 'Check jails', skill: 'jail-status', result: 'success', tokens_used: 420 }), WORKSPACE);
const session = loadSession(tmpDir, 'sysadmin_agent', WORKSPACE);
const ctx = formatSessionContext(session);
expect(ctx).toContain('jail-status');
expect(ctx).toContain('420');
});
it('context includes task, skill, outcome (not full output)', () => {
writeSessionEntry(tmpDir, 'sysadmin', makeEntry({ task: 'Check disk', skill: 'disk-usage', result: 'success', tokens_used: 300 }), WORKSPACE);
const ctx = formatSessionContext(loadSession(tmpDir, 'sysadmin', WORKSPACE));
writeSessionEntry(tmpDir, 'sysadmin_agent', makeEntry({ task: 'Check disk', skill: 'disk-usage', result: 'success', tokens_used: 300 }), WORKSPACE);
const ctx = formatSessionContext(loadSession(tmpDir, 'sysadmin_agent', WORKSPACE));
expect(ctx).toContain('disk-usage');
expect(ctx).toContain('Check disk');
});
it('context is empty string when no session history', () => {
const session = loadSession(tmpDir, 'git_admin', WORKSPACE);
const session = loadSession(tmpDir, 'git_admin_agent', WORKSPACE);
expect(formatSessionContext(session)).toBe('');
});
});

View file

@ -22,12 +22,12 @@ import {
const defaultAgents: Agent[] = [
{ id: 'clawdie', role: 'orchestrator', adapter: 'pi-local', heartbeat_enabled: false, heartbeat_interval_sec: null, budget_allocation_pct: 80, api_key_hash: null, created_at: new Date() },
{ id: 'sysadmin', role: 'sysadmin', adapter: 'pi-local', heartbeat_enabled: true, heartbeat_interval_sec: 86400, budget_allocation_pct: 10, api_key_hash: null, created_at: new Date() },
{ id: 'sysadmin_agent', role: 'sysadmin_agent', adapter: 'pi-local', heartbeat_enabled: true, heartbeat_interval_sec: 86400, budget_allocation_pct: 10, api_key_hash: null, created_at: new Date() },
];
const defaultBudgets: Budget[] = [
{ agent_id: 'clawdie', daily_tokens: 80000, spent_today: 20000, remaining: 60000, hard_limit_exceeded: false, reset_at: new Date() },
{ agent_id: 'sysadmin', daily_tokens: 10000, spent_today: 5000, remaining: 5000, hard_limit_exceeded: false, reset_at: new Date() },
{ agent_id: 'sysadmin_agent', daily_tokens: 10000, spent_today: 5000, remaining: 5000, hard_limit_exceeded: false, reset_at: new Date() },
];
const validAuth = 'Bearer op:admin:testpass';
@ -228,19 +228,19 @@ describe('Control Plane HTTP API — contract tests', () => {
it('filters tasks by ?role= parameter', async () => {
const { handler } = await setup({
tasks: [
{ id: 'T1', title: 'Check jail', description: null, assigned_to: 'sysadmin', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
{ id: 'T2', title: 'Vacuum DB', description: null, assigned_to: 'dba', priority: 'low', status: 'pending', created_at: new Date(), deadline: null, context: null },
{ id: 'T1', title: 'Check jail', description: null, assigned_to: 'sysadmin_agent', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
{ id: 'T2', title: 'Vacuum DB', description: null, assigned_to: 'db_admin_agent', priority: 'low', status: 'pending', created_at: new Date(), deadline: null, context: null },
],
});
const res = await makeRequest(handler, 'GET', '/api/controlplane/tasks?role=sysadmin', undefined, validAuth);
const res = await makeRequest(handler, 'GET', '/api/controlplane/tasks?role=sysadmin_agent', undefined, validAuth);
expect(res.body.tasks).toHaveLength(1);
expect(res.body.tasks[0].assigned_to).toBe('sysadmin');
expect(res.body.tasks[0].assigned_to).toBe('sysadmin_agent');
});
it('each task has task_id, title, description, assigned_to, status', async () => {
const { handler } = await setup({
tasks: [
{ id: 'T1', title: 'Check jail', description: 'desc', assigned_to: 'sysadmin', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
{ id: 'T1', title: 'Check jail', description: 'desc', assigned_to: 'sysadmin_agent', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
],
});
const res = await makeRequest(handler, 'GET', '/api/controlplane/tasks', undefined, validAuth);
@ -254,18 +254,18 @@ describe('Control Plane HTTP API — contract tests', () => {
it('returns empty array when no tasks assigned to role', async () => {
const { handler } = await setup();
const res = await makeRequest(handler, 'GET', '/api/controlplane/tasks?role=git_admin', undefined, validAuth);
const res = await makeRequest(handler, 'GET', '/api/controlplane/tasks?role=git_admin_agent', undefined, validAuth);
expect(res.body.tasks).toHaveLength(0);
});
it('returns pending tasks only (not completed or cancelled)', async () => {
const { handler } = await setup({
tasks: [
{ id: 'T1', title: 'Pending task', description: null, assigned_to: 'sysadmin', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
{ id: 'T1', title: 'Pending task', description: null, assigned_to: 'sysadmin_agent', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
],
filterTasksByStatus: true,
});
const res = await makeRequest(handler, 'GET', '/api/controlplane/tasks?role=sysadmin', undefined, validAuth);
const res = await makeRequest(handler, 'GET', '/api/controlplane/tasks?role=sysadmin_agent', undefined, validAuth);
expect(res.body.tasks).toHaveLength(1);
expect(res.body.tasks[0].status).toBe('pending');
});
@ -276,7 +276,7 @@ describe('Control Plane HTTP API — contract tests', () => {
const { handler } = await setup();
const res = await makeRequest(handler, 'POST', '/api/controlplane/activity', {
event_type: 'task_completed',
agent_id: 'sysadmin',
agent_id: 'sysadmin_agent',
task_id: 'T1',
}, validAuth);
expect(res.statusCode).toBe(200);
@ -287,7 +287,7 @@ describe('Control Plane HTTP API — contract tests', () => {
const { handler } = await setup();
const res = await makeRequest(handler, 'POST', '/api/controlplane/activity', {
event_type: 'approval_request',
agent_id: 'git_admin',
agent_id: 'git_admin_agent',
operation: 'merge PR',
}, validAuth);
expect(res.statusCode).toBe(200);
@ -298,7 +298,7 @@ describe('Control Plane HTTP API — contract tests', () => {
const { handler } = await setup();
const res = await makeRequest(handler, 'POST', '/api/controlplane/activity', {
event_type: 'error',
agent_id: 'dba',
agent_id: 'db_admin_agent',
error_message: 'vacuum failed',
}, validAuth);
expect(res.statusCode).toBe(200);
@ -326,7 +326,7 @@ describe('Control Plane HTTP API — contract tests', () => {
const { handler, insertedActivity } = await setup();
await makeRequest(handler, 'POST', '/api/controlplane/activity', {
event_type: 'task_completed',
agent_id: 'sysadmin',
agent_id: 'sysadmin_agent',
tokens_used: 420,
}, validAuth);
expect(insertedActivity.length).toBeGreaterThan(0);
@ -372,9 +372,9 @@ describe('Control Plane HTTP API — contract tests', () => {
const { createControlplaneApiHandler } = await import('./controlplane-api.js');
const handler = createControlplaneApiHandler({ pool: pool as any, authInstance: mockAuthInstance as any });
const res = await makeRequest(handler, 'GET', '/api/controlplane/approvals?agent_id=sysadmin', undefined, validAuth);
const res = await makeRequest(handler, 'GET', '/api/controlplane/approvals?agent_id=sysadmin_agent', undefined, validAuth);
expect(res.statusCode).toBe(200);
expect(capturedParams?.[0]).toBe('sysadmin');
expect(capturedParams?.[0]).toBe('sysadmin_agent');
});
});

View file

@ -46,7 +46,7 @@ const mainBudget = (spent = 0): Budget => ({
});
const sysadminBudget = (spent = 0): Budget => ({
agent_id: 'sysadmin',
agent_id: 'sysadmin_agent',
daily_tokens: 10000,
spent_today: spent,
remaining: 10000 - spent,
@ -55,7 +55,7 @@ const sysadminBudget = (spent = 0): Budget => ({
});
const dbaBudget = (spent = 0): Budget => ({
agent_id: 'dba',
agent_id: 'db_admin_agent',
daily_tokens: 5000,
spent_today: spent,
remaining: 5000 - spent,
@ -69,34 +69,34 @@ describe('Control Plane Budget Enforcement', () => {
describe('global budget', () => {
it('allows spawn when budget has remaining tokens', async () => {
const pool = makePool([sysadminBudget(1000)]);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin', 500);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin_agent', 500);
expect(result.allowed).toBe(true);
});
it('refuses spawn when budget is exactly 0', async () => {
const pool = makePool([sysadminBudget(10000)]);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin', 1);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin_agent', 1);
expect(result.allowed).toBe(false);
expect(result.reason).toBe('budget_exhausted');
});
it('refuses spawn when estimated_tokens exceeds remaining', async () => {
const pool = makePool([sysadminBudget(9500)]);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin', 600);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin_agent', 600);
expect(result.allowed).toBe(false);
expect(result.reason).toBe('budget_exhausted');
});
it('returns reason="budget_exhausted" on refusal', async () => {
const pool = makePool([sysadminBudget(10000)]);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin', 1);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin_agent', 1);
expect(result.reason).toBe('budget_exhausted');
});
it('hard_limit_exceeded flag triggers immediate stop for all agents', async () => {
// Use a budget with remaining > 0 but hard_limit_exceeded forced true (manual override)
const pool = makePool([{ ...sysadminBudget(5000), hard_limit_exceeded: true }]);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin', 1);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin_agent', 1);
expect(result.allowed).toBe(false);
expect(result.reason).toBe('hard_limit_exceeded');
});
@ -105,19 +105,19 @@ describe('Control Plane Budget Enforcement', () => {
describe('per-agent budget', () => {
it('allows spawn when agent allocation has remaining tokens', async () => {
const pool = makePool([dbaBudget(0)]);
const result = await checkBudget(pool as unknown as Pool, 'dba', 500);
const result = await checkBudget(pool as unknown as Pool, 'db_admin_agent', 500);
expect(result.allowed).toBe(true);
});
it('refuses spawn when agent allocation is exhausted', async () => {
const pool = makePool([dbaBudget(5000)]);
const result = await checkBudget(pool as unknown as Pool, 'dba', 1);
const result = await checkBudget(pool as unknown as Pool, 'db_admin_agent', 1);
expect(result.allowed).toBe(false);
});
it('returns reason="agent_budget_exhausted" — mapped to budget_exhausted', async () => {
const pool = makePool([dbaBudget(5000)]);
const result = await checkBudget(pool as unknown as Pool, 'dba', 1);
const result = await checkBudget(pool as unknown as Pool, 'db_admin_agent', 1);
expect(result.reason).toBe('budget_exhausted');
});
@ -128,15 +128,15 @@ describe('Control Plane Budget Enforcement', () => {
expect(result.reason).toBe('agent_budget_not_found');
});
it('sysadmin allocation is 10% of daily_tokens (10000 of 100000)', async () => {
it('sysadmin_agent allocation is 10% of daily_tokens (10000 of 100000)', async () => {
const pool = makePool([sysadminBudget(0)]);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin', 100);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin_agent', 100);
expect(result.dailyTokens).toBe(10000);
});
it('dba allocation is 5% of daily_tokens (5000 of 100000)', async () => {
it('db_admin_agent allocation is 5% of daily_tokens (5000 of 100000)', async () => {
const pool = makePool([dbaBudget(0)]);
const result = await checkBudget(pool as unknown as Pool, 'dba', 100);
const result = await checkBudget(pool as unknown as Pool, 'db_admin_agent', 100);
expect(result.dailyTokens).toBe(5000);
});
@ -151,7 +151,7 @@ describe('Control Plane Budget Enforcement', () => {
it('recording spend reduces remaining for agent', async () => {
const b = sysadminBudget(0);
const pool = makePool([b]);
await recordTokenSpend(pool as unknown as Pool, 'sysadmin', 3000);
await recordTokenSpend(pool as unknown as Pool, 'sysadmin_agent', 3000);
expect(b.spent_today).toBe(3000);
expect(b.hard_limit_exceeded).toBe(false);
});
@ -159,14 +159,14 @@ describe('Control Plane Budget Enforcement', () => {
it('recording spend above remaining sets hard_limit_exceeded', async () => {
const b = sysadminBudget(9000);
const pool = makePool([b]);
await recordTokenSpend(pool as unknown as Pool, 'sysadmin', 2000);
await recordTokenSpend(pool as unknown as Pool, 'sysadmin_agent', 2000);
expect(b.hard_limit_exceeded).toBe(true);
});
it('spent_today never exceeds daily_tokens after recording', async () => {
const b = sysadminBudget(9000);
const pool = makePool([b]);
await recordTokenSpend(pool as unknown as Pool, 'sysadmin', 5000);
await recordTokenSpend(pool as unknown as Pool, 'sysadmin_agent', 5000);
expect(b.spent_today).toBe(10000);
expect(b.spent_today).toBeLessThanOrEqual(b.daily_tokens);
});
@ -198,23 +198,23 @@ describe('Control Plane Budget Enforcement', () => {
});
describe('approval threshold', () => {
it('operations > 2000 tokens require operator approval (sysadmin)', async () => {
it('operations > 2000 tokens require operator approval (sysadmin_agent)', async () => {
const pool = makePool([sysadminBudget(0)]);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin', 2500, false);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin_agent', 2500, false);
expect(result.allowed).toBe(false);
expect(result.reason).toBe('approval_required');
});
it('operations > 3000 tokens require operator approval (dba)', async () => {
it('operations > 3000 tokens require operator approval (db_admin_agent)', async () => {
const pool = makePool([dbaBudget(0)]);
const result = await checkBudget(pool as unknown as Pool, 'dba', 3500, false);
const result = await checkBudget(pool as unknown as Pool, 'db_admin_agent', 3500, false);
expect(result.allowed).toBe(false);
expect(result.reason).toBe('approval_required');
});
it('operator-approved operations bypass per-agent threshold check', async () => {
const pool = makePool([sysadminBudget(0)]);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin', 5000, true);
const result = await checkBudget(pool as unknown as Pool, 'sysadmin_agent', 5000, true);
expect(result.allowed).toBe(true);
});
});

View file

@ -29,9 +29,9 @@ export interface AgentThresholds {
// ── Constants ──────────────────────────────────────────────────────────────
export const DEFAULT_APPROVAL_THRESHOLDS: AgentThresholds = {
sysadmin: 2000,
dba: 3000,
git_admin: 2000,
sysadmin_agent: 2000,
db_admin_agent: 3000,
git_admin_agent: 2000,
orchestrator: Infinity,
};

View file

@ -13,7 +13,7 @@ import pg from 'pg';
// ── Types ──────────────────────────────────────────────────────────────────
export type AgentRole = 'orchestrator' | 'sysadmin' | 'dba' | 'git_admin';
export type AgentRole = 'orchestrator' | 'sysadmin_agent' | 'db_admin_agent' | 'git_admin_agent';
export interface Agent {
id: string;
@ -75,24 +75,24 @@ export function getDefaultAgents(agentName: string): Omit<Agent, 'api_key_hash'
budget_allocation_pct: 80,
},
{
id: 'sysadmin',
role: 'sysadmin',
id: 'sysadmin_agent',
role: 'sysadmin_agent',
adapter: 'pi-local',
heartbeat_enabled: true,
heartbeat_interval_sec: 86400,
budget_allocation_pct: 10,
},
{
id: 'dba',
role: 'dba',
id: 'db_admin_agent',
role: 'db_admin_agent',
adapter: 'pi-local',
heartbeat_enabled: false,
heartbeat_interval_sec: null,
budget_allocation_pct: 5,
},
{
id: 'git_admin',
role: 'git_admin',
id: 'git_admin_agent',
role: 'git_admin_agent',
adapter: 'pi-local',
heartbeat_enabled: false,
heartbeat_interval_sec: null,
@ -108,7 +108,7 @@ export const DEFAULT_AGENTS = getDefaultAgents('clawdie');
export const CONTROLPLANE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
role TEXT NOT NULL CHECK (role IN ('orchestrator','sysadmin','dba','git_admin')),
role TEXT NOT NULL CHECK (role IN ('orchestrator','sysadmin_agent','db_admin_agent','git_admin_agent')),
adapter TEXT NOT NULL DEFAULT 'pi-local',
heartbeat_enabled BOOLEAN NOT NULL DEFAULT FALSE,
heartbeat_interval_sec INTEGER,

View file

@ -16,7 +16,7 @@ const WORKSPACE = '/home/clawdie/clawdie-ai';
function makeOpts(overrides: Partial<ControlplaneRunOptions> = {}): ControlplaneRunOptions {
return {
agentId: 'sysadmin',
agentId: 'sysadmin_agent',
agentName: 'clawdie',
taskId: 'TASK-001',
apiKey: 'test-api-key',
@ -30,8 +30,8 @@ function makeOpts(overrides: Partial<ControlplaneRunOptions> = {}): Controlplane
describe('Control Plane Runner — env injection', () => {
describe('CONTROLPLANE_* env vars', () => {
it('injects CONTROLPLANE_AGENT_ID', () => {
const result = buildControlplaneRunCommand(makeOpts({ agentId: 'sysadmin' }));
expect(result.env.CONTROLPLANE_AGENT_ID).toBe('sysadmin');
const result = buildControlplaneRunCommand(makeOpts({ agentId: 'sysadmin_agent' }));
expect(result.env.CONTROLPLANE_AGENT_ID).toBe('sysadmin_agent');
});
it('injects CONTROLPLANE_API_URL as http://localhost:3100', () => {
@ -68,8 +68,8 @@ describe('Control Plane Runner — env injection', () => {
});
it('session file path ends with {agent_name}.jsonl', () => {
const result = buildControlplaneRunCommand(makeOpts({ agentId: 'dba' }));
const sessionArg = result.args.find((a) => a.endsWith('dba.jsonl'));
const result = buildControlplaneRunCommand(makeOpts({ agentId: 'db_admin_agent' }));
const sessionArg = result.args.find((a) => a.endsWith('db_admin_agent.jsonl'));
expect(sessionArg).toBeDefined();
});
@ -93,10 +93,10 @@ describe('Control Plane Runner — env injection', () => {
});
it('args include --session with correct project-relative path', () => {
const result = buildControlplaneRunCommand(makeOpts({ agentId: 'sysadmin' }));
const result = buildControlplaneRunCommand(makeOpts({ agentId: 'sysadmin_agent' }));
const idx = result.args.indexOf('--session');
expect(idx).toBeGreaterThanOrEqual(0);
expect(result.args[idx + 1]).toContain('sysadmin.jsonl');
expect(result.args[idx + 1]).toContain('sysadmin_agent.jsonl');
});
it('args include --skills pointing to data/skills/', () => {
@ -107,10 +107,10 @@ describe('Control Plane Runner — env injection', () => {
});
it('args include --append-system-prompt with identity file path', () => {
const result = buildControlplaneRunCommand(makeOpts({ agentId: 'sysadmin' }));
const result = buildControlplaneRunCommand(makeOpts({ agentId: 'sysadmin_agent' }));
const idx = result.args.indexOf('--append-system-prompt');
expect(idx).toBeGreaterThanOrEqual(0);
expect(result.args[idx + 1]).toContain('SYSADMIN.md');
expect(result.args[idx + 1]).toContain('SYSADMIN_AGENT.md');
});
it('heartbeat prompt text is passed as final arg', () => {

View file

@ -38,9 +38,9 @@ export interface RunResult {
const CONTROLPLANE_API_URL = 'http://localhost:3100';
const AGENT_IDENTITY_FILES: Record<string, string> = {
sysadmin: 'SYSADMIN.md',
dba: 'DBA.md',
git_admin: 'GIT_ADMIN.md',
sysadmin_agent: 'SYSADMIN_AGENT.md',
db_admin_agent: 'DB_ADMIN_AGENT.md',
git_admin_agent: 'GIT_ADMIN_AGENT.md',
};
export function resolveIdentityFile(agentId: string, agentName: string, workspaceCwd: string): string {

View file

@ -49,22 +49,22 @@ describe('Control Plane Provisioning', () => {
expect(main?.role).toBe('orchestrator');
});
it('DEFAULT_AGENTS contains Sysadmin agent with role "sysadmin"', () => {
const agent = DEFAULT_AGENTS.find((a) => a.id === 'sysadmin');
it('DEFAULT_AGENTS contains Sysadmin agent with role "sysadmin_agent"', () => {
const agent = DEFAULT_AGENTS.find((a) => a.id === 'sysadmin_agent');
expect(agent).toBeDefined();
expect(agent?.role).toBe('sysadmin');
expect(agent?.role).toBe('sysadmin_agent');
});
it('DEFAULT_AGENTS contains DBA agent with role "dba"', () => {
const agent = DEFAULT_AGENTS.find((a) => a.id === 'dba');
it('DEFAULT_AGENTS contains DBA agent with role "db_admin_agent"', () => {
const agent = DEFAULT_AGENTS.find((a) => a.id === 'db_admin_agent');
expect(agent).toBeDefined();
expect(agent?.role).toBe('dba');
expect(agent?.role).toBe('db_admin_agent');
});
it('DEFAULT_AGENTS contains Git Admin agent with role "git_admin"', () => {
const agent = DEFAULT_AGENTS.find((a) => a.id === 'git_admin');
it('DEFAULT_AGENTS contains Git Admin agent with role "git_admin_agent"', () => {
const agent = DEFAULT_AGENTS.find((a) => a.id === 'git_admin_agent');
expect(agent).toBeDefined();
expect(agent?.role).toBe('git_admin');
expect(agent?.role).toBe('git_admin_agent');
});
it('all agents use adapter "pi-local"', () => {
@ -86,19 +86,19 @@ describe('Control Plane Provisioning', () => {
});
it('Sysadmin has heartbeat_enabled = true and interval = 86400', () => {
const agent = DEFAULT_AGENTS.find((a) => a.id === 'sysadmin')!;
const agent = DEFAULT_AGENTS.find((a) => a.id === 'sysadmin_agent')!;
expect(agent.heartbeat_enabled).toBe(true);
expect(agent.heartbeat_interval_sec).toBe(86400);
});
it('DBA has heartbeat_enabled = false', () => {
const agent = DEFAULT_AGENTS.find((a) => a.id === 'dba')!;
const agent = DEFAULT_AGENTS.find((a) => a.id === 'db_admin_agent')!;
expect(agent.heartbeat_enabled).toBe(false);
expect(agent.heartbeat_interval_sec).toBeNull();
});
it('Git Admin has heartbeat_enabled = false', () => {
const agent = DEFAULT_AGENTS.find((a) => a.id === 'git_admin')!;
const agent = DEFAULT_AGENTS.find((a) => a.id === 'git_admin_agent')!;
expect(agent.heartbeat_enabled).toBe(false);
expect(agent.heartbeat_interval_sec).toBeNull();
});
@ -111,17 +111,17 @@ describe('Control Plane Provisioning', () => {
});
it('Sysadmin gets 10% allocation', () => {
const agent = DEFAULT_AGENTS.find((a) => a.id === 'sysadmin')!;
const agent = DEFAULT_AGENTS.find((a) => a.id === 'sysadmin_agent')!;
expect(agent.budget_allocation_pct).toBe(10);
});
it('DBA gets 5% allocation', () => {
const agent = DEFAULT_AGENTS.find((a) => a.id === 'dba')!;
const agent = DEFAULT_AGENTS.find((a) => a.id === 'db_admin_agent')!;
expect(agent.budget_allocation_pct).toBe(5);
});
it('Git Admin gets 5% allocation', () => {
const agent = DEFAULT_AGENTS.find((a) => a.id === 'git_admin')!;
const agent = DEFAULT_AGENTS.find((a) => a.id === 'git_admin_agent')!;
expect(agent.budget_allocation_pct).toBe(5);
});

View file

@ -52,7 +52,7 @@ function makePool(): Pool {
return { rows: [] };
}
if (/FROM agent_budgets/i.test(sql)) {
return { rows: [{ agent_id: 'sysadmin', daily_tokens: 10000, spent_today: 0, remaining: 10000, hard_limit_exceeded: false, reset_at: new Date() }] };
return { rows: [{ agent_id: 'sysadmin_agent', daily_tokens: 10000, spent_today: 0, remaining: 10000, hard_limit_exceeded: false, reset_at: new Date() }] };
}
if (/FROM agents\b/i.test(sql)) return { rows: [] };
if (/FROM tasks\b/i.test(sql)) return { rows: [] };
@ -137,25 +137,25 @@ describe('Telegram → Control Plane Bridge', () => {
});
describe('agent routing', () => {
it('routes jail messages to sysadmin', async () => {
it('routes jail messages to sysadmin_agent', async () => {
writeSkill('jail-status', ['Check jail *', 'Is * jail running']);
const pool = makePool();
const result = await bridgeTelegramMessage(pool, makeConfig(pool), 'Check jail db');
expect(result?.assignedTo).toBe('sysadmin');
expect(result?.assignedTo).toBe('sysadmin_agent');
});
it('routes database messages to dba', async () => {
it('routes database messages to db_admin_agent', async () => {
writeSkill('backup-db', ['backup * database', 'run database backup']);
const pool = makePool();
const result = await bridgeTelegramMessage(pool, makeConfig(pool), 'backup the database');
expect(result?.assignedTo).toBe('dba');
expect(result?.assignedTo).toBe('db_admin_agent');
});
it('routes git messages to git_admin', async () => {
it('routes git messages to git_admin_agent', async () => {
writeSkill('git-status', ['check git repo', 'git status *']);
const pool = makePool();
const result = await bridgeTelegramMessage(pool, makeConfig(pool), 'check git repo');
expect(result?.assignedTo).toBe('git_admin');
expect(result?.assignedTo).toBe('git_admin_agent');
});
it('routes unrecognised agent messages to orchestrator', async () => {
@ -192,7 +192,7 @@ describe('Telegram → Control Plane Bridge', () => {
writeSkill('jail-status', ['Check jail *']);
const pool = makePool();
await bridgeTelegramMessage(pool, makeConfig(pool), 'Check jail db');
expect(insertedTasks[0].assigned_to).toBe('sysadmin');
expect(insertedTasks[0].assigned_to).toBe('sysadmin_agent');
});
it('posts agent activity after waking agent', async () => {

View file

@ -31,11 +31,11 @@ export interface TelegramBridgeResult {
// ── Agent routing ──────────────────────────────────────────────────────────
// Maps skill category keywords to agent roles.
// Sysadmin handles infra; DBA handles data; git_admin handles code; orchestrator gets everything else.
// Sysadmin handles infra; DBA handles data; git_admin_agent handles code; orchestrator gets everything else.
const ROLE_KEYWORDS: Array<{ role: string; patterns: RegExp[] }> = [
{ role: 'sysadmin', patterns: [/jail/i, /service/i, /network/i, /disk/i, /cpu/i, /memory/i, /reboot/i, /cert/i, /nginx/i, /tailscale/i] },
{ role: 'dba', patterns: [/\bdatabase\b/i, /\bpostgres\b/i, /\bvacuum\b/i, /\bdb\b/i, /\bmigration\b/i] },
{ role: 'git_admin', patterns: [/\bgit\b/i, /\brepo\b/i, /\bcommit\b/i, /\bmerge\b/i, /\bbranch\b/i, /pull request/i, /\bpr\b/i] },
{ role: 'sysadmin_agent', patterns: [/jail/i, /service/i, /network/i, /disk/i, /cpu/i, /memory/i, /reboot/i, /cert/i, /nginx/i, /tailscale/i] },
{ role: 'db_admin_agent', patterns: [/\bdatabase\b/i, /\bpostgres\b/i, /\bvacuum\b/i, /\bdb\b/i, /\bmigration\b/i] },
{ role: 'git_admin_agent', patterns: [/\bgit\b/i, /\brepo\b/i, /\bcommit\b/i, /\bmerge\b/i, /\bbranch\b/i, /pull request/i, /\bpr\b/i] },
];
function inferRole(message: string, skillName: string | undefined, skillConfidence: string, fallbackAgent: string): string {

View file

@ -53,20 +53,20 @@ afterEach(() => {
const defaultAgents: Agent[] = [
{ id: 'clawdie', role: 'orchestrator', adapter: 'pi-local', heartbeat_enabled: false, heartbeat_interval_sec: null, budget_allocation_pct: 80, api_key_hash: null, created_at: new Date() },
{ id: 'sysadmin', role: 'sysadmin', adapter: 'pi-local', heartbeat_enabled: true, heartbeat_interval_sec: 86400, budget_allocation_pct: 10, api_key_hash: null, created_at: new Date() },
{ id: 'dba', role: 'dba', adapter: 'pi-local', heartbeat_enabled: false, heartbeat_interval_sec: null, budget_allocation_pct: 5, api_key_hash: null, created_at: new Date() },
{ id: 'git_admin', role: 'git_admin', adapter: 'pi-local', heartbeat_enabled: false, heartbeat_interval_sec: null, budget_allocation_pct: 5, api_key_hash: null, created_at: new Date() },
{ id: 'sysadmin_agent', role: 'sysadmin_agent', adapter: 'pi-local', heartbeat_enabled: true, heartbeat_interval_sec: 86400, budget_allocation_pct: 10, api_key_hash: null, created_at: new Date() },
{ id: 'db_admin_agent', role: 'db_admin_agent', adapter: 'pi-local', heartbeat_enabled: false, heartbeat_interval_sec: null, budget_allocation_pct: 5, api_key_hash: null, created_at: new Date() },
{ id: 'git_admin_agent', role: 'git_admin_agent', adapter: 'pi-local', heartbeat_enabled: false, heartbeat_interval_sec: null, budget_allocation_pct: 5, api_key_hash: null, created_at: new Date() },
];
const defaultBudgets: Budget[] = [
{ agent_id: 'clawdie', daily_tokens: 80000, spent_today: 0, remaining: 80000, hard_limit_exceeded: false, reset_at: new Date() },
{ agent_id: 'sysadmin', daily_tokens: 10000, spent_today: 5000, remaining: 5000, hard_limit_exceeded: false, reset_at: new Date() },
{ agent_id: 'dba', daily_tokens: 5000, spent_today: 0, remaining: 5000, hard_limit_exceeded: false, reset_at: new Date() },
{ agent_id: 'git_admin', daily_tokens: 5000, spent_today: 0, remaining: 5000, hard_limit_exceeded: false, reset_at: new Date() },
{ agent_id: 'sysadmin_agent', daily_tokens: 10000, spent_today: 5000, remaining: 5000, hard_limit_exceeded: false, reset_at: new Date() },
{ agent_id: 'db_admin_agent', daily_tokens: 5000, spent_today: 0, remaining: 5000, hard_limit_exceeded: false, reset_at: new Date() },
{ agent_id: 'git_admin_agent', daily_tokens: 5000, spent_today: 0, remaining: 5000, hard_limit_exceeded: false, reset_at: new Date() },
];
const defaultTasks: Task[] = [
{ id: 'TASK-001', title: 'Check if db jail is running', description: 'Verify db jail is up', assigned_to: 'sysadmin', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
{ id: 'TASK-001', title: 'Check if db jail is running', description: 'Verify db jail is up', assigned_to: 'sysadmin_agent', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
];
let activityLog: Array<{ agent_id: string; event_type: string; payload: Record<string, unknown> | null; tokens_used: number | null }> = [];
@ -110,15 +110,15 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
it('queries control plane and attempts agent spawn', async () => {
const pool = makePool();
const config = makeConfig(pool);
const result = await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
const result = await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
expect(result.woke).toBe(true);
expect(result.agentId).toBe('sysadmin');
expect(result.agentId).toBe('sysadmin_agent');
});
it('receives task assignment via tasks query', async () => {
const pool = makePool();
const config = makeConfig(pool);
await runAgentHeartbeat(config, 'sysadmin', 'assignment', 'TASK-001');
await runAgentHeartbeat(config, 'sysadmin_agent', 'assignment', 'TASK-001');
const taskQuery = vi.mocked(pool.query).mock.calls.find((c) => /FROM tasks/i.test(c[0] as string));
expect(taskQuery).toBeDefined();
});
@ -126,23 +126,23 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
it('posts activity event after execution (success or error)', async () => {
const pool = makePool();
const config = makeConfig(pool);
await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
expect(activityLog.length).toBeGreaterThan(0);
});
it('activity event includes agent_id', async () => {
const pool = makePool();
const config = makeConfig(pool);
await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
const events = activityLog.filter((e) => e.agent_id === 'sysadmin');
await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
const events = activityLog.filter((e) => e.agent_id === 'sysadmin_agent');
expect(events.length).toBeGreaterThan(0);
});
it('session JSONL is updated after completion', async () => {
const pool = makePool();
const config = makeConfig(pool);
await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
const sessionFile = path.join(sessionDir, 'sysadmin.jsonl');
await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
const sessionFile = path.join(sessionDir, 'sysadmin_agent.jsonl');
if (fs.existsSync(sessionFile)) {
const content = fs.readFileSync(sessionFile, 'utf-8');
expect(content.length).toBeGreaterThan(0);
@ -152,22 +152,22 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
describe('DBA on-demand task', () => {
const dbaTasks: Task[] = [
{ id: 'TASK-002', title: 'Back up database', description: 'Full backup', assigned_to: 'dba', priority: 'high', status: 'pending', created_at: new Date(), deadline: null, context: null },
{ id: 'TASK-002', title: 'Back up database', description: 'Full backup', assigned_to: 'db_admin_agent', priority: 'high', status: 'pending', created_at: new Date(), deadline: null, context: null },
];
it('DBA is woken for assignment task', async () => {
const pool = makePool(defaultAgents, defaultBudgets, dbaTasks);
const config = makeConfig(pool);
const result = await runAgentHeartbeat(config, 'dba', 'assignment', 'TASK-002');
const result = await runAgentHeartbeat(config, 'db_admin_agent', 'assignment', 'TASK-002');
expect(result.woke).toBe(true);
});
it('DBA queries tasks and finds its task', async () => {
const pool = makePool(defaultAgents, defaultBudgets, dbaTasks);
const config = makeConfig(pool);
await runAgentHeartbeat(config, 'dba', 'assignment', 'TASK-002');
await runAgentHeartbeat(config, 'db_admin_agent', 'assignment', 'TASK-002');
const taskQuery = vi.mocked(pool.query).mock.calls.find((c) =>
/FROM tasks/i.test(c[0] as string) && (c[1] as unknown[])?.[0] === 'dba',
/FROM tasks/i.test(c[0] as string) && (c[1] as unknown[])?.[0] === 'db_admin_agent',
);
expect(taskQuery).toBeDefined();
});
@ -175,8 +175,8 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
it('DBA posts task_completed with result', async () => {
const pool = makePool(defaultAgents, defaultBudgets, dbaTasks);
const config = makeConfig(pool);
await runAgentHeartbeat(config, 'dba', 'assignment', 'TASK-002');
const completed = activityLog.filter((e) => e.event_type === 'task_completed' && e.agent_id === 'dba');
await runAgentHeartbeat(config, 'db_admin_agent', 'assignment', 'TASK-002');
const completed = activityLog.filter((e) => e.event_type === 'task_completed' && e.agent_id === 'db_admin_agent');
expect(completed.length).toBeGreaterThanOrEqual(0);
});
});
@ -184,22 +184,22 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
describe('budget exhaustion during cycle', () => {
it('scheduler does not wake agent when budget is exhausted', async () => {
const exhaustedBudgets: Budget[] = [
{ agent_id: 'sysadmin', daily_tokens: 10000, spent_today: 10000, remaining: 0, hard_limit_exceeded: true, reset_at: new Date() },
{ agent_id: 'sysadmin_agent', daily_tokens: 10000, spent_today: 10000, remaining: 0, hard_limit_exceeded: true, reset_at: new Date() },
];
const pool = makePool(defaultAgents, exhaustedBudgets, defaultTasks);
const config = makeConfig(pool);
const result = await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
const result = await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
expect(result.woke).toBe(false);
expect(result.reason).toBe('hard_limit_exceeded');
});
it('scheduler posts error event instead of spawning', async () => {
const exhaustedBudgets: Budget[] = [
{ agent_id: 'sysadmin', daily_tokens: 10000, spent_today: 10000, remaining: 0, hard_limit_exceeded: true, reset_at: new Date() },
{ agent_id: 'sysadmin_agent', daily_tokens: 10000, spent_today: 10000, remaining: 0, hard_limit_exceeded: true, reset_at: new Date() },
];
const pool = makePool(defaultAgents, exhaustedBudgets, defaultTasks);
const config = makeConfig(pool);
await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
const errorEvents = activityLog.filter((e) => e.event_type === 'error');
expect(errorEvents.length).toBeGreaterThan(0);
});
@ -210,15 +210,15 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
const pool = makePool();
const config = makeConfig(pool);
config.skillsDir = '/nonexistent/skills/path';
const result = await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
expect(result.agentId).toBe('sysadmin');
const result = await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
expect(result.agentId).toBe('sysadmin_agent');
});
it('error event includes action_taken field', async () => {
const pool = makePool();
const config = makeConfig(pool);
config.skillsDir = '/nonexistent/skills/path';
await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
if (activityLog.length > 0) {
const hasActionTaken = activityLog.some((e) => {
if (e.payload && typeof e.payload === 'object') return 'action_taken' in e.payload;
@ -232,7 +232,7 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
const pool = makePool();
const config = makeConfig(pool);
config.skillsDir = '/nonexistent';
const result = await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
const result = await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
expect(result).toBeDefined();
expect(result).toHaveProperty('agentId');
});
@ -240,20 +240,20 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
describe('Telegram task bridge', () => {
const telegramTasks: Task[] = [
{ id: 'TASK-TG1', title: 'Check jails for Telegram user', description: 'From main chat', assigned_to: 'sysadmin', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
{ id: 'TASK-TG1', title: 'Check jails for Telegram user', description: 'From main chat', assigned_to: 'sysadmin_agent', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
];
it('incoming task is processable by agent', async () => {
const pool = makePool(defaultAgents, defaultBudgets, telegramTasks);
const config = makeConfig(pool);
const result = await runAgentHeartbeat(config, 'sysadmin', 'telegram', 'TASK-TG1');
expect(result.agentId).toBe('sysadmin');
const result = await runAgentHeartbeat(config, 'sysadmin_agent', 'telegram', 'TASK-TG1');
expect(result.agentId).toBe('sysadmin_agent');
});
it('task completion posts activity event', async () => {
const pool = makePool(defaultAgents, defaultBudgets, telegramTasks);
const config = makeConfig(pool);
await runAgentHeartbeat(config, 'sysadmin', 'telegram', 'TASK-TG1');
await runAgentHeartbeat(config, 'sysadmin_agent', 'telegram', 'TASK-TG1');
expect(activityLog.length).toBeGreaterThan(0);
});
});

View file

@ -101,7 +101,7 @@ describe('Skills Discovery', () => {
});
});
describe('skill pattern matching — sysadmin', () => {
describe('skill pattern matching — sysadmin_agent', () => {
const sysadminSkills: Skill[] = [
{ name: 'jail-status', description: '', compatibility: 'FreeBSD 15.0+', invoke_patterns: ['Check if * jail is running', 'Is * up', 'Jail status'] },
{ name: 'disk-usage', description: '', compatibility: 'FreeBSD 15.0+', invoke_patterns: ['How much free disk*', 'Disk space*'] },
@ -115,8 +115,8 @@ describe('Skills Discovery', () => {
expect(matchTaskToSkill('Check if db jail is running', sysadminSkills).skill!.name).toBe('jail-status');
});
it('"Is sysadmin-db up?" → jail-status', () => {
expect(matchTaskToSkill('Is sysadmin-db up?', sysadminSkills).skill!.name).toBe('jail-status');
it('"Is sysadmin_agent-db up?" → jail-status', () => {
expect(matchTaskToSkill('Is sysadmin_agent-db up?', sysadminSkills).skill!.name).toBe('jail-status');
});
it('"How much free disk space?" → disk-usage', () => {
@ -144,7 +144,7 @@ describe('Skills Discovery', () => {
});
});
describe('skill pattern matching — dba', () => {
describe('skill pattern matching — db_admin_agent', () => {
const dbaSkills: Skill[] = [
{ name: 'db-vacuum', description: '', compatibility: 'FreeBSD 15.0+', invoke_patterns: ['Run vacuum*', 'Vacuum database*'] },
{ name: 'db-analyze', description: '', compatibility: 'FreeBSD 15.0+', invoke_patterns: ['Analyze the database*', 'Analyze*'] },