Remove legacy agent IDs + tighten task API
- Canonicalize controlplane agent IDs/roles to: sysadmin, db-admin, git-admin (drop *_agent variants). - Add DB migration to rewrite existing *_agent rows and references to canonical IDs. - Tighten POST /api/controlplane/tasks contract: require assigned_to (remove agent_id alias). - Update tests and docs to match canonical IDs. --- Build: pass (just typecheck) Tests: pass — 1536 passed (92 files) (just test)
This commit is contained in:
parent
befd8bb3f4
commit
c633fdcc49
22 changed files with 286 additions and 253 deletions
|
|
@ -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=db_admin_agent
|
||||
GET /api/controlplane/tasks?role=db-admin
|
||||
→ [
|
||||
{
|
||||
task_id: "TASK-042",
|
||||
|
|
@ -49,7 +49,7 @@ GET /api/controlplane/tasks?role=db_admin_agent
|
|||
|
||||
### Layer 2: Clawdie (What Do I Know?)
|
||||
```
|
||||
Read data/sessions/db_admin_agent.jsonl
|
||||
Read data/sessions/db-admin.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": "db_admin_agent",
|
||||
"agent_id": "db-admin",
|
||||
"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/db_admin_agent.jsonl`.
|
||||
Your session lives in `data/sessions/db-admin.jsonl`.
|
||||
|
||||
Each time you complete a job:
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -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_agent
|
||||
GET /api/controlplane/tasks?role=git-admin
|
||||
→ [
|
||||
{
|
||||
task_id: "TASK-105",
|
||||
|
|
@ -54,7 +54,7 @@ GET /api/controlplane/tasks?role=git_admin_agent
|
|||
|
||||
### Layer 2: Clawdie (What Do I Know?)
|
||||
```
|
||||
Read /home/clawdie/clawdie-ai/data/sessions/git_admin_agent.jsonl
|
||||
Read /home/clawdie/clawdie-ai/data/sessions/git-admin.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",
|
||||
"agent_id": "git-admin",
|
||||
"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_agent.jsonl`.
|
||||
Your session lives in `/home/clawdie/clawdie-ai/data/sessions/git-admin.jsonl`.
|
||||
|
||||
Each time you complete a job:
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ Same pattern:
|
|||
|
||||
### Layer 1: Control Plane (What's My Job?)
|
||||
```
|
||||
GET /api/controlplane/tasks?role=sysadmin_agent
|
||||
GET /api/controlplane/tasks?role=sysadmin
|
||||
→ [
|
||||
{ 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_agent
|
|||
|
||||
### Layer 2: Clawdie (What Do I Know?)
|
||||
```
|
||||
Read data/sessions/sysadmin_agent.jsonl
|
||||
Read data/sessions/sysadmin.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_agent-db is running (uptime 5d 3h)` |
|
||||
| "Check if X jail is running" or "Is X up?" | `jail-status` | `sysadmin-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_agent-db: 2GB limit, 1.2GB used` |
|
||||
| "Check RCTL/quotas" | `resource-limits` | `sysadmin-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",
|
||||
"agent_id": "sysadmin",
|
||||
"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_agent.jsonl`.
|
||||
Your session lives in `/home/clawdie/clawdie-ai/data/sessions/sysadmin.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_agent-db running, uptime 5d 3h, CPU 2%, RAM 512/2048MB",
|
||||
"output": "sysadmin-db running, uptime 5d 3h, CPU 2%, RAM 512/2048MB",
|
||||
"tokens_used": 420
|
||||
}
|
||||
```
|
||||
|
||||
Next heartbeat, you read this file. If you see "sysadmin_agent-db was running yesterday at 10:30," you know:
|
||||
Next heartbeat, you read this file. If you see "sysadmin-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"
|
||||
|
|
|
|||
|
|
@ -31,18 +31,18 @@ Authorization: Bearer {CONTROLPLANE_SHARED_SECRET}
|
|||
"agents": [
|
||||
{ "id": "clawdie", "role": "orchestrator", "heartbeat_enabled": false },
|
||||
{
|
||||
"id": "sysadmin_agent",
|
||||
"role": "sysadmin_agent",
|
||||
"id": "sysadmin",
|
||||
"role": "sysadmin",
|
||||
"heartbeat_enabled": true
|
||||
},
|
||||
{
|
||||
"id": "db_admin_agent",
|
||||
"role": "db_admin_agent",
|
||||
"id": "db-admin",
|
||||
"role": "db-admin",
|
||||
"heartbeat_enabled": false
|
||||
},
|
||||
{
|
||||
"id": "git_admin_agent",
|
||||
"role": "git_admin_agent",
|
||||
"id": "git-admin",
|
||||
"role": "git-admin",
|
||||
"heartbeat_enabled": false
|
||||
}
|
||||
],
|
||||
|
|
@ -53,9 +53,9 @@ Authorization: Bearer {CONTROLPLANE_SHARED_SECRET}
|
|||
"hard_limit_exceeded": false,
|
||||
"allocation": {
|
||||
"orchestrator": 80000,
|
||||
"sysadmin_agent": 10000,
|
||||
"db_admin_agent": 5000,
|
||||
"git_admin_agent": 5000
|
||||
"sysadmin": 10000,
|
||||
"db-admin": 5000,
|
||||
"git-admin": 5000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,7 +79,7 @@ Authorization: Bearer {CONTROLPLANE_SHARED_SECRET}
|
|||
"task_id": "TASK-001",
|
||||
"title": "Check if db jail is running",
|
||||
"description": "Verify clawdie-db is up and healthy",
|
||||
"assigned_to": "sysadmin_agent",
|
||||
"assigned_to": "sysadmin",
|
||||
"priority": "medium",
|
||||
"status": "pending",
|
||||
"created_at": "2026-04-07T10:30:00Z",
|
||||
|
|
@ -161,7 +161,7 @@ Authorization: Bearer {CONTROLPLANE_SHARED_SECRET}
|
|||
{
|
||||
"event_type": "task_completed",
|
||||
"task_id": "TASK-001",
|
||||
"agent_id": "sysadmin_agent",
|
||||
"agent_id": "sysadmin",
|
||||
"skill_executed": "jail-status",
|
||||
"result": {
|
||||
"status": "success",
|
||||
|
|
@ -179,7 +179,7 @@ Authorization: Bearer {CONTROLPLANE_SHARED_SECRET}
|
|||
|
||||
{
|
||||
"event_type": "approval_request",
|
||||
"agent_id": "git_admin_agent",
|
||||
"agent_id": "git-admin",
|
||||
"operation": "Merge PR #42 with conflict resolution",
|
||||
"reasoning": "Conflict detected in src/index.ts",
|
||||
"estimated_tokens": 8500
|
||||
|
|
@ -194,7 +194,7 @@ Authorization: Bearer {CONTROLPLANE_SHARED_SECRET}
|
|||
|
||||
{
|
||||
"event_type": "error",
|
||||
"agent_id": "db_admin_agent",
|
||||
"agent_id": "db-admin",
|
||||
"error_message": "Vacuum failed: database locked",
|
||||
"action_taken": "Escalated to orchestrator",
|
||||
"tokens_used": 1200
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ Maps task context to the best provider:
|
|||
|
||||
```typescript
|
||||
interface TaskProfile {
|
||||
agentRole: string; // orchestrator, sysadmin_agent, db_admin_agent, git_admin_agent
|
||||
agentRole: string; // orchestrator, sysadmin, db-admin, git-admin
|
||||
skillMatched: boolean; // skill catalog matched exactly
|
||||
complexity: 'routine' | 'moderate' | 'complex';
|
||||
requiresReasoning: boolean;
|
||||
|
|
|
|||
|
|
@ -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_agent tasks, heartbeat interpretation, pi TUI
|
||||
- Good for: routine sysadmin tasks, heartbeat interpretation, pi TUI
|
||||
- Weak at: complex multi-step tool chains
|
||||
|
||||
```sh
|
||||
|
|
|
|||
|
|
@ -198,7 +198,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_agent tasks |
|
||||
| `freebsd-admin` | Host sysadmin tasks |
|
||||
| `sanoid` | ZFS snapshots — now must cover `/home/clawdie` too |
|
||||
|
||||
### Skills to PORT FROM NanoClaw (we're missing)
|
||||
|
|
|
|||
|
|
@ -45,22 +45,22 @@ 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_agent', makeEntry(), WORKSPACE);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'sysadmin_agent.jsonl'))).toBe(
|
||||
writeSessionEntry(tmpDir, 'sysadmin', makeEntry(), WORKSPACE);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'sysadmin.jsonl'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('appends entries, one JSON object per line', () => {
|
||||
writeSessionEntry(tmpDir, 'sysadmin_agent', makeEntry(), WORKSPACE);
|
||||
writeSessionEntry(tmpDir, 'sysadmin', makeEntry(), WORKSPACE);
|
||||
writeSessionEntry(
|
||||
tmpDir,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
makeEntry({ task: 'Check disk' }),
|
||||
WORKSPACE,
|
||||
);
|
||||
const content = fs.readFileSync(
|
||||
path.join(tmpDir, 'sysadmin_agent.jsonl'),
|
||||
path.join(tmpDir, 'sysadmin.jsonl'),
|
||||
'utf-8',
|
||||
);
|
||||
const lines = content.trim().split('\n');
|
||||
|
|
@ -68,8 +68,8 @@ describe('Agent Session Persistence', () => {
|
|||
});
|
||||
|
||||
it('each entry includes timestamp, task, skill, result, tokens_used', () => {
|
||||
writeSessionEntry(tmpDir, 'sysadmin_agent', makeEntry(), WORKSPACE);
|
||||
const session = loadSession(tmpDir, 'sysadmin_agent', WORKSPACE);
|
||||
writeSessionEntry(tmpDir, 'sysadmin', makeEntry(), WORKSPACE);
|
||||
const session = loadSession(tmpDir, 'sysadmin', WORKSPACE);
|
||||
const entry = session.entries[0];
|
||||
expect(entry).toHaveProperty('timestamp');
|
||||
expect(entry).toHaveProperty('task');
|
||||
|
|
@ -82,13 +82,13 @@ describe('Agent Session Persistence', () => {
|
|||
for (let i = 0; i < 5; i++) {
|
||||
writeSessionEntry(
|
||||
tmpDir,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
makeEntry({ task: `Task ${i}` }),
|
||||
WORKSPACE,
|
||||
);
|
||||
}
|
||||
const content = fs.readFileSync(
|
||||
path.join(tmpDir, 'sysadmin_agent.jsonl'),
|
||||
path.join(tmpDir, 'sysadmin.jsonl'),
|
||||
'utf-8',
|
||||
);
|
||||
for (const line of content.trim().split('\n')) {
|
||||
|
|
@ -99,19 +99,19 @@ describe('Agent Session Persistence', () => {
|
|||
|
||||
describe('loading sessions', () => {
|
||||
it('loads all entries from existing JSONL file', () => {
|
||||
writeSessionEntry(tmpDir, 'db_admin_agent', makeEntry(), WORKSPACE);
|
||||
writeSessionEntry(tmpDir, 'db-admin', makeEntry(), WORKSPACE);
|
||||
writeSessionEntry(
|
||||
tmpDir,
|
||||
'db_admin_agent',
|
||||
'db-admin',
|
||||
makeEntry({ task: 'Vacuum' }),
|
||||
WORKSPACE,
|
||||
);
|
||||
const session = loadSession(tmpDir, 'db_admin_agent', WORKSPACE);
|
||||
const session = loadSession(tmpDir, 'db-admin', WORKSPACE);
|
||||
expect(session.entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns empty entries array when file does not exist', () => {
|
||||
const session = loadSession(tmpDir, 'git_admin_agent', WORKSPACE);
|
||||
const session = loadSession(tmpDir, 'git-admin', WORKSPACE);
|
||||
expect(session.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
|
@ -124,17 +124,17 @@ describe('Agent Session Persistence', () => {
|
|||
it('entries are in chronological order (oldest first)', () => {
|
||||
writeSessionEntry(
|
||||
tmpDir,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
makeEntry({ task: 'First', timestamp: '2026-04-07T09:00:00Z' }),
|
||||
WORKSPACE,
|
||||
);
|
||||
writeSessionEntry(
|
||||
tmpDir,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
makeEntry({ task: 'Second', timestamp: '2026-04-07T10:00:00Z' }),
|
||||
WORKSPACE,
|
||||
);
|
||||
const session = loadSession(tmpDir, 'sysadmin_agent', WORKSPACE);
|
||||
const session = loadSession(tmpDir, 'sysadmin', WORKSPACE);
|
||||
expect(session.entries[0].task).toBe('First');
|
||||
expect(session.entries[1].task).toBe('Second');
|
||||
});
|
||||
|
|
@ -143,32 +143,32 @@ describe('Agent Session Persistence', () => {
|
|||
describe('resilience', () => {
|
||||
it('skips malformed lines (not crashing on bad JSON)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'sysadmin_agent.jsonl'),
|
||||
path.join(tmpDir, 'sysadmin.jsonl'),
|
||||
'not-json\n',
|
||||
'utf-8',
|
||||
);
|
||||
writeSessionEntry(tmpDir, 'sysadmin_agent', makeEntry(), WORKSPACE);
|
||||
const session = loadSession(tmpDir, 'sysadmin_agent', WORKSPACE);
|
||||
writeSessionEntry(tmpDir, 'sysadmin', makeEntry(), WORKSPACE);
|
||||
const session = loadSession(tmpDir, 'sysadmin', WORKSPACE);
|
||||
expect(session.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still loads valid lines when one line is corrupt', () => {
|
||||
const filePath = path.join(tmpDir, 'db_admin_agent.jsonl');
|
||||
const filePath = path.join(tmpDir, 'db-admin.jsonl');
|
||||
fs.writeFileSync(filePath, '', 'utf-8');
|
||||
writeSessionEntry(
|
||||
tmpDir,
|
||||
'db_admin_agent',
|
||||
'db-admin',
|
||||
makeEntry({ task: 'Valid' }),
|
||||
WORKSPACE,
|
||||
);
|
||||
fs.appendFileSync(filePath, 'bad{json\n');
|
||||
writeSessionEntry(
|
||||
tmpDir,
|
||||
'db_admin_agent',
|
||||
'db-admin',
|
||||
makeEntry({ task: 'Also valid' }),
|
||||
WORKSPACE,
|
||||
);
|
||||
const session = loadSession(tmpDir, 'db_admin_agent', WORKSPACE);
|
||||
const session = loadSession(tmpDir, 'db-admin', WORKSPACE);
|
||||
expect(session.entries).toHaveLength(2);
|
||||
expect(
|
||||
session.entries.every(
|
||||
|
|
@ -194,7 +194,7 @@ describe('Agent Session Persistence', () => {
|
|||
it('session file path is under project root (workspace rule)', () => {
|
||||
const filePath = resolveSessionPath(
|
||||
'data/sessions',
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
WORKSPACE,
|
||||
);
|
||||
expect(filePath).toMatch(new RegExp(`^${WORKSPACE}`));
|
||||
|
|
@ -202,13 +202,13 @@ describe('Agent Session Persistence', () => {
|
|||
|
||||
it('rejects sessionDir pointing to /tmp/', () => {
|
||||
expect(() =>
|
||||
resolveSessionPath('/tmp', 'sysadmin_agent', WORKSPACE),
|
||||
resolveSessionPath('/tmp', 'sysadmin', WORKSPACE),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects sessionDir with path traversal (../)', () => {
|
||||
expect(() =>
|
||||
resolveSessionPath('../../../etc', 'sysadmin_agent', WORKSPACE),
|
||||
resolveSessionPath('../../../etc', 'sysadmin', WORKSPACE),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -218,12 +218,12 @@ describe('Agent Session Persistence', () => {
|
|||
for (let i = 0; i < 10; i++) {
|
||||
writeSessionEntry(
|
||||
tmpDir,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
makeEntry({ task: `Task ${i}` }),
|
||||
WORKSPACE,
|
||||
);
|
||||
}
|
||||
const session = loadSession(tmpDir, 'sysadmin_agent', WORKSPACE);
|
||||
const session = loadSession(tmpDir, 'sysadmin', WORKSPACE);
|
||||
const pruned = pruneOldEntries(session, 3);
|
||||
expect(pruned.entries).toHaveLength(3);
|
||||
expect(pruned.entries[0].task).toBe('Task 7');
|
||||
|
|
@ -231,8 +231,8 @@ describe('Agent Session Persistence', () => {
|
|||
});
|
||||
|
||||
it('prune does not modify file when entries <= maxEntries', () => {
|
||||
writeSessionEntry(tmpDir, 'db_admin_agent', makeEntry(), WORKSPACE);
|
||||
const session = loadSession(tmpDir, 'db_admin_agent', WORKSPACE);
|
||||
writeSessionEntry(tmpDir, 'db-admin', makeEntry(), WORKSPACE);
|
||||
const session = loadSession(tmpDir, 'db-admin', WORKSPACE);
|
||||
const before = fs.readFileSync(session.filePath, 'utf-8');
|
||||
pruneOldEntries(session, 10);
|
||||
const after = fs.readFileSync(session.filePath, 'utf-8');
|
||||
|
|
@ -259,7 +259,7 @@ describe('Agent Session Persistence', () => {
|
|||
it('last N entries formatted for system prompt injection', () => {
|
||||
writeSessionEntry(
|
||||
tmpDir,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
makeEntry({
|
||||
task: 'Check jails',
|
||||
skill: 'jail-status',
|
||||
|
|
@ -268,7 +268,7 @@ describe('Agent Session Persistence', () => {
|
|||
}),
|
||||
WORKSPACE,
|
||||
);
|
||||
const session = loadSession(tmpDir, 'sysadmin_agent', WORKSPACE);
|
||||
const session = loadSession(tmpDir, 'sysadmin', WORKSPACE);
|
||||
const ctx = formatSessionContext(session);
|
||||
expect(ctx).toContain('jail-status');
|
||||
expect(ctx).toContain('420');
|
||||
|
|
@ -277,7 +277,7 @@ describe('Agent Session Persistence', () => {
|
|||
it('context includes task, skill, outcome (not full output)', () => {
|
||||
writeSessionEntry(
|
||||
tmpDir,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
makeEntry({
|
||||
task: 'Check disk',
|
||||
skill: 'disk-usage',
|
||||
|
|
@ -287,14 +287,14 @@ describe('Agent Session Persistence', () => {
|
|||
WORKSPACE,
|
||||
);
|
||||
const ctx = formatSessionContext(
|
||||
loadSession(tmpDir, 'sysadmin_agent', WORKSPACE),
|
||||
loadSession(tmpDir, 'sysadmin', 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_agent', WORKSPACE);
|
||||
const session = loadSession(tmpDir, 'git-admin', WORKSPACE);
|
||||
expect(formatSessionContext(session)).toBe('');
|
||||
});
|
||||
|
||||
|
|
@ -302,7 +302,7 @@ describe('Agent Session Persistence', () => {
|
|||
for (let i = 0; i < 8; i++) {
|
||||
writeSessionEntry(
|
||||
tmpDir,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
makeEntry({
|
||||
task: `Task ${i}`,
|
||||
skill: 'test',
|
||||
|
|
@ -312,7 +312,7 @@ describe('Agent Session Persistence', () => {
|
|||
WORKSPACE,
|
||||
);
|
||||
}
|
||||
const session = loadSession(tmpDir, 'sysadmin_agent', WORKSPACE);
|
||||
const session = loadSession(tmpDir, 'sysadmin', WORKSPACE);
|
||||
const ctx = formatSessionContext(session, 3);
|
||||
const lines = ctx.split('\n').filter((l: string) => l.trim());
|
||||
expect(lines).toHaveLength(3);
|
||||
|
|
@ -321,7 +321,7 @@ describe('Agent Session Persistence', () => {
|
|||
it('includes error result type in context', () => {
|
||||
writeSessionEntry(
|
||||
tmpDir,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
makeEntry({
|
||||
task: 'Failing task',
|
||||
skill: 'none',
|
||||
|
|
@ -331,7 +331,7 @@ describe('Agent Session Persistence', () => {
|
|||
WORKSPACE,
|
||||
);
|
||||
const ctx = formatSessionContext(
|
||||
loadSession(tmpDir, 'sysadmin_agent', WORKSPACE),
|
||||
loadSession(tmpDir, 'sysadmin', WORKSPACE),
|
||||
);
|
||||
expect(ctx).toContain('error');
|
||||
expect(ctx).toContain('Failing task');
|
||||
|
|
|
|||
|
|
@ -29,12 +29,12 @@ vi.mock('better-auth/node', () => ({
|
|||
|
||||
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_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: '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() },
|
||||
];
|
||||
|
||||
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_agent', daily_tokens: 10000, spent_today: 5000, remaining: 5000, 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() },
|
||||
];
|
||||
|
||||
const validAuth = 'Bearer op:admin:testpass';
|
||||
|
|
@ -263,19 +263,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_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 },
|
||||
{ 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: 'db-admin', priority: 'low', status: 'pending', created_at: new Date(), deadline: null, context: null },
|
||||
],
|
||||
});
|
||||
const res = await makeRequest(handler, 'GET', '/api/controlplane/tasks?role=sysadmin_agent', undefined, validAuth);
|
||||
const res = await makeRequest(handler, 'GET', '/api/controlplane/tasks?role=sysadmin', undefined, validAuth);
|
||||
expect(res.body.tasks).toHaveLength(1);
|
||||
expect(res.body.tasks[0].assigned_to).toBe('sysadmin_agent');
|
||||
expect(res.body.tasks[0].assigned_to).toBe('sysadmin');
|
||||
});
|
||||
|
||||
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_agent', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
|
||||
{ id: 'T1', title: 'Check jail', description: 'desc', assigned_to: 'sysadmin', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
|
||||
],
|
||||
});
|
||||
const res = await makeRequest(handler, 'GET', '/api/controlplane/tasks', undefined, validAuth);
|
||||
|
|
@ -289,18 +289,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_agent', undefined, validAuth);
|
||||
const res = await makeRequest(handler, 'GET', '/api/controlplane/tasks?role=git-admin', 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_agent', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
|
||||
{ id: 'T1', title: 'Pending task', description: null, assigned_to: 'sysadmin', 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_agent', undefined, validAuth);
|
||||
const res = await makeRequest(handler, 'GET', '/api/controlplane/tasks?role=sysadmin', undefined, validAuth);
|
||||
expect(res.body.tasks).toHaveLength(1);
|
||||
expect(res.body.tasks[0].status).toBe('pending');
|
||||
});
|
||||
|
|
@ -311,7 +311,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',
|
||||
agent_id: 'sysadmin',
|
||||
task_id: 'T1',
|
||||
}, validAuth);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
|
@ -322,7 +322,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',
|
||||
agent_id: 'git-admin',
|
||||
operation: 'merge PR',
|
||||
}, validAuth);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
|
@ -333,7 +333,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: 'db_admin_agent',
|
||||
agent_id: 'db-admin',
|
||||
error_message: 'vacuum failed',
|
||||
}, validAuth);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
|
@ -361,7 +361,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',
|
||||
agent_id: 'sysadmin',
|
||||
tokens_used: 420,
|
||||
}, validAuth);
|
||||
expect(insertedActivity.length).toBeGreaterThan(0);
|
||||
|
|
@ -415,9 +415,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_agent', undefined, validAuth);
|
||||
const res = await makeRequest(handler, 'GET', '/api/controlplane/approvals?agent_id=sysadmin', undefined, validAuth);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(capturedParams?.[0]).toBe('sysadmin_agent');
|
||||
expect(capturedParams?.[0]).toBe('sysadmin');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -462,9 +462,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/activity?agent_id=sysadmin_agent&event_type=task_completed&limit=10', undefined, validAuth);
|
||||
const res = await makeRequest(handler, 'GET', '/api/controlplane/activity?agent_id=sysadmin&event_type=task_completed&limit=10', undefined, validAuth);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(capturedParams).toContain('sysadmin_agent');
|
||||
expect(capturedParams).toContain('sysadmin');
|
||||
expect(capturedParams).toContain('task_completed');
|
||||
});
|
||||
});
|
||||
|
|
@ -473,7 +473,7 @@ describe('Control Plane HTTP API — contract tests', () => {
|
|||
it('updates task status and returns updated task', async () => {
|
||||
const { handler } = await setup({
|
||||
tasks: [
|
||||
{ id: 'T1', title: 'Check jail', description: null, assigned_to: 'sysadmin_agent', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
|
||||
{ id: 'T1', title: 'Check jail', description: null, assigned_to: 'sysadmin', priority: 'medium', status: 'pending', created_at: new Date(), deadline: null, context: null },
|
||||
],
|
||||
});
|
||||
const res = await makeRequest(handler, 'PATCH', '/api/controlplane/tasks/T1', { status: 'in_progress' }, validAuth);
|
||||
|
|
@ -508,7 +508,7 @@ describe('Control Plane HTTP API — contract tests', () => {
|
|||
describe('POST /api/controlplane/approvals/:id/approve', () => {
|
||||
const pendingApproval = {
|
||||
id: 'APPR-001',
|
||||
agent_id: 'git_admin_agent',
|
||||
agent_id: 'git-admin',
|
||||
task_id: null,
|
||||
operation: 'merge PR',
|
||||
estimated_tokens: null,
|
||||
|
|
@ -544,7 +544,7 @@ describe('Control Plane HTTP API — contract tests', () => {
|
|||
describe('POST /api/controlplane/approvals/:id/reject', () => {
|
||||
const pendingApproval = {
|
||||
id: 'APPR-002',
|
||||
agent_id: 'db_admin_agent',
|
||||
agent_id: 'db-admin',
|
||||
task_id: null,
|
||||
operation: 'drop table',
|
||||
estimated_tokens: null,
|
||||
|
|
@ -568,7 +568,7 @@ describe('Control Plane HTTP API — contract tests', () => {
|
|||
const deadline = new Date('2026-05-01T00:00:00Z');
|
||||
const { handler } = await setup({
|
||||
tasks: [
|
||||
{ id: 'T1', title: 'Test', description: 'desc', assigned_to: 'sysadmin_agent', priority: 'high', status: 'pending', created_at: new Date(), deadline, context: { jail: 'clawdie-db' } },
|
||||
{ id: 'T1', title: 'Test', description: 'desc', assigned_to: 'sysadmin', priority: 'high', status: 'pending', created_at: new Date(), deadline, context: { jail: 'clawdie-db' } },
|
||||
],
|
||||
});
|
||||
const res = await makeRequest(handler, 'GET', '/api/controlplane/tasks', undefined, validAuth);
|
||||
|
|
|
|||
|
|
@ -302,11 +302,12 @@ async function handlePostTask(
|
|||
deadlineValue = parsedDeadline;
|
||||
}
|
||||
|
||||
const assignedToRaw =
|
||||
const assignedTo =
|
||||
typeof assigned_to === 'string' ? assigned_to : undefined;
|
||||
// Back-compat: some clients send `agent_id` instead of `assigned_to`.
|
||||
const agentIdRaw = typeof parsed.agent_id === 'string' ? parsed.agent_id : undefined;
|
||||
const assignedTo = assignedToRaw ?? agentIdRaw;
|
||||
if (!assignedTo) {
|
||||
jsonResponse(res, 400, { error: 'missing assigned_to' });
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = `API-${Date.now().toString(36).toUpperCase()}`;
|
||||
await createTask(pool, {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const mainBudget = (spent = 0): Budget => ({
|
|||
});
|
||||
|
||||
const sysadminBudget = (spent = 0): Budget => ({
|
||||
agent_id: 'sysadmin_agent',
|
||||
agent_id: 'sysadmin',
|
||||
daily_tokens: 10000,
|
||||
spent_today: spent,
|
||||
remaining: 10000 - spent,
|
||||
|
|
@ -61,7 +61,7 @@ const sysadminBudget = (spent = 0): Budget => ({
|
|||
});
|
||||
|
||||
const dbaBudget = (spent = 0): Budget => ({
|
||||
agent_id: 'db_admin_agent',
|
||||
agent_id: 'db-admin',
|
||||
daily_tokens: 5000,
|
||||
spent_today: spent,
|
||||
remaining: 5000 - spent,
|
||||
|
|
@ -77,7 +77,7 @@ describe('Control Plane Budget Enforcement', () => {
|
|||
const pool = makePool([sysadminBudget(1000)]);
|
||||
const result = await checkBudget(
|
||||
pool as unknown as Pool,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
500,
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
|
|
@ -87,7 +87,7 @@ describe('Control Plane Budget Enforcement', () => {
|
|||
const pool = makePool([sysadminBudget(10000)]);
|
||||
const result = await checkBudget(
|
||||
pool as unknown as Pool,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
1,
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
|
|
@ -98,7 +98,7 @@ describe('Control Plane Budget Enforcement', () => {
|
|||
const pool = makePool([sysadminBudget(9500)]);
|
||||
const result = await checkBudget(
|
||||
pool as unknown as Pool,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
600,
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
|
|
@ -109,7 +109,7 @@ describe('Control Plane Budget Enforcement', () => {
|
|||
const pool = makePool([sysadminBudget(10000)]);
|
||||
const result = await checkBudget(
|
||||
pool as unknown as Pool,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
1,
|
||||
);
|
||||
expect(result.reason).toBe('budget_exhausted');
|
||||
|
|
@ -122,7 +122,7 @@ describe('Control Plane Budget Enforcement', () => {
|
|||
]);
|
||||
const result = await checkBudget(
|
||||
pool as unknown as Pool,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
1,
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
|
|
@ -135,7 +135,7 @@ describe('Control Plane Budget Enforcement', () => {
|
|||
const pool = makePool([dbaBudget(0)]);
|
||||
const result = await checkBudget(
|
||||
pool as unknown as Pool,
|
||||
'db_admin_agent',
|
||||
'db-admin',
|
||||
500,
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
|
|
@ -145,7 +145,7 @@ describe('Control Plane Budget Enforcement', () => {
|
|||
const pool = makePool([dbaBudget(5000)]);
|
||||
const result = await checkBudget(
|
||||
pool as unknown as Pool,
|
||||
'db_admin_agent',
|
||||
'db-admin',
|
||||
1,
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
|
|
@ -155,7 +155,7 @@ describe('Control Plane Budget Enforcement', () => {
|
|||
const pool = makePool([dbaBudget(5000)]);
|
||||
const result = await checkBudget(
|
||||
pool as unknown as Pool,
|
||||
'db_admin_agent',
|
||||
'db-admin',
|
||||
1,
|
||||
);
|
||||
expect(result.reason).toBe('budget_exhausted');
|
||||
|
|
@ -172,21 +172,21 @@ describe('Control Plane Budget Enforcement', () => {
|
|||
expect(result.reason).toBe('agent_budget_not_found');
|
||||
});
|
||||
|
||||
it('sysadmin_agent allocation is 10% of daily_tokens (10000 of 100000)', async () => {
|
||||
it('sysadmin 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_agent',
|
||||
'sysadmin',
|
||||
100,
|
||||
);
|
||||
expect(result.dailyTokens).toBe(10000);
|
||||
});
|
||||
|
||||
it('db_admin_agent allocation is 5% of daily_tokens (5000 of 100000)', async () => {
|
||||
it('db-admin allocation is 5% of daily_tokens (5000 of 100000)', async () => {
|
||||
const pool = makePool([dbaBudget(0)]);
|
||||
const result = await checkBudget(
|
||||
pool as unknown as Pool,
|
||||
'db_admin_agent',
|
||||
'db-admin',
|
||||
100,
|
||||
);
|
||||
expect(result.dailyTokens).toBe(5000);
|
||||
|
|
@ -203,7 +203,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_agent', 3000);
|
||||
await recordTokenSpend(pool as unknown as Pool, 'sysadmin', 3000);
|
||||
expect(b.spent_today).toBe(3000);
|
||||
expect(b.hard_limit_exceeded).toBe(false);
|
||||
});
|
||||
|
|
@ -211,14 +211,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_agent', 2000);
|
||||
await recordTokenSpend(pool as unknown as Pool, 'sysadmin', 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_agent', 5000);
|
||||
await recordTokenSpend(pool as unknown as Pool, 'sysadmin', 5000);
|
||||
expect(b.spent_today).toBe(10000);
|
||||
expect(b.spent_today).toBeLessThanOrEqual(b.daily_tokens);
|
||||
});
|
||||
|
|
@ -258,11 +258,11 @@ describe('Control Plane Budget Enforcement', () => {
|
|||
});
|
||||
|
||||
describe('approval threshold', () => {
|
||||
it('operations > 2000 tokens require operator approval (sysadmin_agent)', async () => {
|
||||
it('operations > 2000 tokens require operator approval (sysadmin)', async () => {
|
||||
const pool = makePool([sysadminBudget(0)]);
|
||||
const result = await checkBudget(
|
||||
pool as unknown as Pool,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
2500,
|
||||
false,
|
||||
);
|
||||
|
|
@ -270,11 +270,11 @@ describe('Control Plane Budget Enforcement', () => {
|
|||
expect(result.reason).toBe('approval_required');
|
||||
});
|
||||
|
||||
it('operations > 3000 tokens require operator approval (db_admin_agent)', async () => {
|
||||
it('operations > 3000 tokens require operator approval (db-admin)', async () => {
|
||||
const pool = makePool([dbaBudget(0)]);
|
||||
const result = await checkBudget(
|
||||
pool as unknown as Pool,
|
||||
'db_admin_agent',
|
||||
'db-admin',
|
||||
3500,
|
||||
false,
|
||||
);
|
||||
|
|
@ -286,17 +286,17 @@ describe('Control Plane Budget Enforcement', () => {
|
|||
const pool = makePool([sysadminBudget(0)]);
|
||||
const result = await checkBudget(
|
||||
pool as unknown as Pool,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
5000,
|
||||
true,
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('operations > 2000 tokens require operator approval (git_admin_agent)', async () => {
|
||||
it('operations > 2000 tokens require operator approval (git-admin)', async () => {
|
||||
const pool = makePool([
|
||||
{
|
||||
agent_id: 'git_admin_agent',
|
||||
agent_id: 'git-admin',
|
||||
daily_tokens: 10000,
|
||||
spent_today: 0,
|
||||
remaining: 10000,
|
||||
|
|
@ -306,7 +306,7 @@ describe('Control Plane Budget Enforcement', () => {
|
|||
]);
|
||||
const result = await checkBudget(
|
||||
pool as unknown as Pool,
|
||||
'git_admin_agent',
|
||||
'git-admin',
|
||||
2500,
|
||||
false,
|
||||
);
|
||||
|
|
@ -318,7 +318,7 @@ describe('Control Plane Budget Enforcement', () => {
|
|||
const pool = makePool([sysadminBudget(0)]);
|
||||
const result = await checkBudget(
|
||||
pool as unknown as Pool,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
10000,
|
||||
true,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ export interface AgentThresholds {
|
|||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_APPROVAL_THRESHOLDS: AgentThresholds = {
|
||||
sysadmin_agent: 2000,
|
||||
db_admin_agent: 3000,
|
||||
git_admin_agent: 2000,
|
||||
sysadmin: 2000,
|
||||
'db-admin': 3000,
|
||||
'git-admin': 2000,
|
||||
orchestrator: Infinity,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -53,9 +53,9 @@ describe('getDefaultAgents', () => {
|
|||
const agents = getDefaultAgents('clawdie');
|
||||
const ids = agents.map((a) => a.id);
|
||||
expect(ids).toContain('clawdie');
|
||||
expect(ids).toContain('sysadmin_agent');
|
||||
expect(ids).toContain('db_admin_agent');
|
||||
expect(ids).toContain('git_admin_agent');
|
||||
expect(ids).toContain('sysadmin');
|
||||
expect(ids).toContain('db-admin');
|
||||
expect(ids).toContain('git-admin');
|
||||
expect(ids).toContain('coordinator');
|
||||
});
|
||||
|
||||
|
|
@ -65,9 +65,9 @@ describe('getDefaultAgents', () => {
|
|||
expect(total).toBe(100);
|
||||
});
|
||||
|
||||
it('sysadmin_agent has heartbeat_enabled', () => {
|
||||
it('sysadmin has heartbeat_enabled', () => {
|
||||
const agents = getDefaultAgents('clawdie');
|
||||
const sysadmin = agents.find((a) => a.id === 'sysadmin_agent');
|
||||
const sysadmin = agents.find((a) => a.id === 'sysadmin');
|
||||
expect(sysadmin?.heartbeat_enabled).toBe(true);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,6 @@ import { AGENT_NAME } from './config.js';
|
|||
|
||||
export type AgentRole =
|
||||
| 'orchestrator'
|
||||
| 'sysadmin_agent'
|
||||
| 'db_admin_agent'
|
||||
| 'git_admin_agent'
|
||||
| 'sysadmin'
|
||||
| 'db-admin'
|
||||
| 'git-admin'
|
||||
|
|
@ -88,24 +85,24 @@ export function getDefaultAgents(
|
|||
budget_allocation_pct: 80,
|
||||
},
|
||||
{
|
||||
id: 'sysadmin_agent',
|
||||
role: 'sysadmin_agent',
|
||||
id: 'sysadmin',
|
||||
role: 'sysadmin',
|
||||
adapter: 'pi-local',
|
||||
heartbeat_enabled: true,
|
||||
heartbeat_interval_sec: 86400,
|
||||
budget_allocation_pct: 10,
|
||||
},
|
||||
{
|
||||
id: 'db_admin_agent',
|
||||
role: 'db_admin_agent',
|
||||
id: 'db-admin',
|
||||
role: 'db-admin',
|
||||
adapter: 'pi-local',
|
||||
heartbeat_enabled: false,
|
||||
heartbeat_interval_sec: null,
|
||||
budget_allocation_pct: 5,
|
||||
},
|
||||
{
|
||||
id: 'git_admin_agent',
|
||||
role: 'git_admin_agent',
|
||||
id: 'git-admin',
|
||||
role: 'git-admin',
|
||||
adapter: 'pi-local',
|
||||
heartbeat_enabled: false,
|
||||
heartbeat_interval_sec: null,
|
||||
|
|
@ -129,7 +126,7 @@ export const DEFAULT_AGENTS = getDefaultAgents(AGENT_NAME);
|
|||
export const CONTROLPLANE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
role TEXT NOT NULL CHECK (role IN ('orchestrator','sysadmin_agent','db_admin_agent','git_admin_agent','coordinator')),
|
||||
role TEXT NOT NULL CHECK (role IN ('orchestrator','sysadmin','db-admin','git-admin','coordinator')),
|
||||
adapter TEXT NOT NULL DEFAULT 'pi-local',
|
||||
heartbeat_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
heartbeat_interval_sec INTEGER,
|
||||
|
|
@ -187,7 +184,7 @@ CREATE TABLE IF NOT EXISTS operators (
|
|||
|
||||
export async function runSchemaMigration(pool: pg.Pool): Promise<void> {
|
||||
await pool.query(CONTROLPLANE_SCHEMA_SQL);
|
||||
await cleanupLegacyAgents(pool);
|
||||
await migrateLegacyAgentIds(pool);
|
||||
await ensureRoleConstraint(pool);
|
||||
await ensureApprovalDecisionColumn(pool);
|
||||
await ensureParentTaskId(pool);
|
||||
|
|
@ -211,28 +208,75 @@ export async function runSchemaMigration(pool: pg.Pool): Promise<void> {
|
|||
|
||||
}
|
||||
|
||||
async function cleanupLegacyAgents(pool: pg.Pool): Promise<void> {
|
||||
// One-time cleanup: remove legacy agent IDs that predate the current naming.
|
||||
// After this runs once, all subsequent calls are no-ops (cheap SELECTs).
|
||||
const legacyIds = ['sysadmin', 'db-admin', 'git-admin', 'dba', 'git_admin', 'ceo'];
|
||||
async function migrateLegacyAgentIds(pool: pg.Pool): Promise<void> {
|
||||
// Aggressive dev policy: canonical agent IDs are:
|
||||
// sysadmin, db-admin, git-admin, coordinator, plus orchestrator = AGENT_NAME.
|
||||
// Migrate old *_agent IDs in-place once (idempotent).
|
||||
const legacyToCanonical: Array<[string, string]> = [
|
||||
['sysadmin_agent', 'sysadmin'],
|
||||
['db_admin_agent', 'db-admin'],
|
||||
['git_admin_agent', 'git-admin'],
|
||||
];
|
||||
|
||||
const legacyIds = legacyToCanonical.map(([legacy]) => legacy);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id FROM agents WHERE id = ANY($1)`,
|
||||
[legacyIds],
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
// Drop FKs, clean up, re-add.
|
||||
await pool.query('ALTER TABLE agents DROP CONSTRAINT IF EXISTS agents_role_check');
|
||||
for (const table of ['tasks', 'agent_activity', 'agent_budgets', 'approvals']) {
|
||||
await pool.query(`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_${table === 'tasks' ? 'assigned_to' : 'agent_id'}_fkey`);
|
||||
}
|
||||
await pool.query('BEGIN');
|
||||
try {
|
||||
await pool.query(
|
||||
'ALTER TABLE agents DROP CONSTRAINT IF EXISTS agents_role_check',
|
||||
);
|
||||
for (const table of [
|
||||
'tasks',
|
||||
'agent_activity',
|
||||
'agent_budgets',
|
||||
'approvals',
|
||||
]) {
|
||||
await pool.query(
|
||||
`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_${table === 'tasks' ? 'assigned_to' : 'agent_id'}_fkey`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const id of legacyIds) {
|
||||
await pool.query('DELETE FROM agent_budgets WHERE agent_id = $1', [id]);
|
||||
await pool.query('DELETE FROM agent_activity WHERE agent_id = $1', [id]);
|
||||
await pool.query('DELETE FROM approvals WHERE agent_id = $1', [id]);
|
||||
await pool.query('UPDATE tasks SET assigned_to = NULL WHERE assigned_to = $1', [id]);
|
||||
await pool.query('DELETE FROM agents WHERE id = $1', [id]);
|
||||
for (const [legacy, canonical] of legacyToCanonical) {
|
||||
await pool.query(
|
||||
'UPDATE tasks SET assigned_to = $1 WHERE assigned_to = $2',
|
||||
[canonical, legacy],
|
||||
);
|
||||
await pool.query(
|
||||
'UPDATE agent_activity SET agent_id = $1 WHERE agent_id = $2',
|
||||
[canonical, legacy],
|
||||
);
|
||||
await pool.query(
|
||||
'UPDATE agent_budgets SET agent_id = $1 WHERE agent_id = $2',
|
||||
[canonical, legacy],
|
||||
);
|
||||
await pool.query(
|
||||
'UPDATE approvals SET agent_id = $1 WHERE agent_id = $2',
|
||||
[canonical, legacy],
|
||||
);
|
||||
|
||||
const canonicalExists = await pool.query(
|
||||
'SELECT 1 FROM agents WHERE id = $1 LIMIT 1',
|
||||
[canonical],
|
||||
);
|
||||
if (canonicalExists.rows.length > 0) {
|
||||
await pool.query('DELETE FROM agents WHERE id = $1', [legacy]);
|
||||
} else {
|
||||
await pool.query(
|
||||
'UPDATE agents SET id = $1, role = $1 WHERE id = $2',
|
||||
[canonical, legacy],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await pool.query('COMMIT');
|
||||
} catch (err) {
|
||||
await pool.query('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -243,7 +287,7 @@ async function ensureRoleConstraint(pool: pg.Pool): Promise<void> {
|
|||
await pool.query(
|
||||
`ALTER TABLE agents
|
||||
ADD CONSTRAINT agents_role_check
|
||||
CHECK (role IN ('orchestrator','sysadmin_agent','db_admin_agent','git_admin_agent','coordinator'))`,
|
||||
CHECK (role IN ('orchestrator','sysadmin','db-admin','git-admin','coordinator'))`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ describe('runAgentHeartbeat — no task', () => {
|
|||
const config = makeConfig();
|
||||
const result = await runAgentHeartbeat(
|
||||
config,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
'interval_elapsed',
|
||||
);
|
||||
expect(result.woke).toBe(false);
|
||||
|
|
@ -55,7 +55,7 @@ describe('runAgentHeartbeat — no task', () => {
|
|||
const config = makeConfig();
|
||||
const result = await runAgentHeartbeat(
|
||||
config,
|
||||
'sysadmin_agent',
|
||||
'sysadmin',
|
||||
'telegram',
|
||||
);
|
||||
expect(result.woke).toBe(false);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ function makeOpts(
|
|||
overrides: Partial<ControlplaneRunOptions> = {},
|
||||
): ControlplaneRunOptions {
|
||||
return {
|
||||
agentId: 'sysadmin_agent',
|
||||
agentId: 'sysadmin',
|
||||
agentName: 'clawdie',
|
||||
taskId: 'TASK-001',
|
||||
apiKey: 'test-api-key',
|
||||
|
|
@ -38,9 +38,9 @@ describe('Control Plane Runner — env injection', () => {
|
|||
describe('CONTROLPLANE_* env vars', () => {
|
||||
it('injects CONTROLPLANE_AGENT_ID', () => {
|
||||
const result = buildControlplaneRunCommand(
|
||||
makeOpts({ agentId: 'sysadmin_agent' }),
|
||||
makeOpts({ agentId: 'sysadmin' }),
|
||||
);
|
||||
expect(result.env.CONTROLPLANE_AGENT_ID).toBe('sysadmin_agent');
|
||||
expect(result.env.CONTROLPLANE_AGENT_ID).toBe('sysadmin');
|
||||
});
|
||||
|
||||
it('injects CONTROLPLANE_API_URL as http://localhost:3100', () => {
|
||||
|
|
@ -86,10 +86,10 @@ describe('Control Plane Runner — env injection', () => {
|
|||
|
||||
it('session file path ends with {agent_name}.jsonl', () => {
|
||||
const result = buildControlplaneRunCommand(
|
||||
makeOpts({ agentId: 'db_admin_agent' }),
|
||||
makeOpts({ agentId: 'db-admin' }),
|
||||
);
|
||||
const sessionArg = result.args.find((a) =>
|
||||
a.endsWith('db_admin_agent.jsonl'),
|
||||
a.endsWith('db-admin.jsonl'),
|
||||
);
|
||||
expect(sessionArg).toBeDefined();
|
||||
});
|
||||
|
|
@ -115,11 +115,11 @@ describe('Control Plane Runner — env injection', () => {
|
|||
|
||||
it('args include --session with correct project-relative path', () => {
|
||||
const result = buildControlplaneRunCommand(
|
||||
makeOpts({ agentId: 'sysadmin_agent' }),
|
||||
makeOpts({ agentId: 'sysadmin' }),
|
||||
);
|
||||
const idx = result.args.indexOf('--session');
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
expect(result.args[idx + 1]).toContain('sysadmin_agent.jsonl');
|
||||
expect(result.args[idx + 1]).toContain('sysadmin.jsonl');
|
||||
});
|
||||
|
||||
it('args include --no-skills to disable pi skill discovery', () => {
|
||||
|
|
@ -130,7 +130,7 @@ describe('Control Plane Runner — env injection', () => {
|
|||
|
||||
it('args include --append-system-prompt with identity content', () => {
|
||||
const result = buildControlplaneRunCommand(
|
||||
makeOpts({ agentId: 'sysadmin_agent' }),
|
||||
makeOpts({ agentId: 'sysadmin' }),
|
||||
);
|
||||
const idx = result.args.indexOf('--append-system-prompt');
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
|
|
@ -171,25 +171,25 @@ describe('Control Plane Runner — env injection', () => {
|
|||
});
|
||||
|
||||
describe('resolveIdentityFile', () => {
|
||||
it('maps sysadmin_agent to SYSADMIN identity content', () => {
|
||||
it('maps sysadmin to SYSADMIN identity content', () => {
|
||||
const result = buildControlplaneRunCommand(
|
||||
makeOpts({ agentId: 'sysadmin_agent' }),
|
||||
makeOpts({ agentId: 'sysadmin' }),
|
||||
);
|
||||
const promptIdx = result.args.indexOf('--append-system-prompt');
|
||||
expect(result.args[promptIdx + 1]).toContain('Sysadmin Agent');
|
||||
});
|
||||
|
||||
it('maps db_admin_agent to DB_ADMIN identity content', () => {
|
||||
it('maps db-admin to DB_ADMIN identity content', () => {
|
||||
const result = buildControlplaneRunCommand(
|
||||
makeOpts({ agentId: 'db_admin_agent' }),
|
||||
makeOpts({ agentId: 'db-admin' }),
|
||||
);
|
||||
const promptIdx = result.args.indexOf('--append-system-prompt');
|
||||
expect(result.args[promptIdx + 1]).toContain('DB Admin Agent');
|
||||
});
|
||||
|
||||
it('maps git_admin_agent to GIT_ADMIN identity content', () => {
|
||||
it('maps git-admin to GIT_ADMIN identity content', () => {
|
||||
const result = buildControlplaneRunCommand(
|
||||
makeOpts({ agentId: 'git_admin_agent' }),
|
||||
makeOpts({ agentId: 'git-admin' }),
|
||||
);
|
||||
const promptIdx = result.args.indexOf('--append-system-prompt');
|
||||
expect(result.args[promptIdx + 1]).toContain('Git Admin Agent');
|
||||
|
|
@ -204,17 +204,14 @@ describe('Control Plane Runner — env injection', () => {
|
|||
describe('AGENT_JAIL_MAP', () => {
|
||||
it('sysadmin runs on host (null)', () => {
|
||||
expect(AGENT_JAIL_MAP['sysadmin']).toBeNull();
|
||||
expect(AGENT_JAIL_MAP['sysadmin_agent']).toBeNull();
|
||||
});
|
||||
|
||||
it('db-admin routes to db-worker jail', () => {
|
||||
expect(AGENT_JAIL_MAP['db-admin']).toBe('db-worker');
|
||||
expect(AGENT_JAIL_MAP['db_admin_agent']).toBe('db-worker');
|
||||
});
|
||||
|
||||
it('git-admin routes to git-worker jail', () => {
|
||||
expect(AGENT_JAIL_MAP['git-admin']).toBe('git-worker');
|
||||
expect(AGENT_JAIL_MAP['git_admin_agent']).toBe('git-worker');
|
||||
});
|
||||
|
||||
it('coordinator routes to ctrl-worker jail', () => {
|
||||
|
|
|
|||
|
|
@ -46,11 +46,7 @@ export interface RunResult {
|
|||
const CONTROLPLANE_API_URL = `http://localhost:${CONTROLPLANE_API_PORT}`;
|
||||
|
||||
const AGENT_IDENTITY_FILES: Record<string, string> = {
|
||||
// Legacy agent IDs (pre-Phase 6)
|
||||
sysadmin_agent: '.agent/identities/SYSADMIN.md',
|
||||
db_admin_agent: '.agent/identities/DB_ADMIN.md',
|
||||
git_admin_agent: '.agent/identities/GIT_ADMIN.md',
|
||||
// Phase 6 canonical IDs (as defined in agent/library.yaml)
|
||||
// Canonical IDs (as defined in agent/library.yaml)
|
||||
sysadmin: '.agent/identities/SYSADMIN.md',
|
||||
'db-admin': '.agent/identities/DB_ADMIN.md',
|
||||
'git-admin': '.agent/identities/GIT_ADMIN.md',
|
||||
|
|
@ -58,14 +54,9 @@ const AGENT_IDENTITY_FILES: Record<string, string> = {
|
|||
};
|
||||
|
||||
export const AGENT_JAIL_MAP: Record<string, string | null> = {
|
||||
// Phase 6 canonical IDs (as defined in agent/library.yaml)
|
||||
sysadmin: null,
|
||||
'db-admin': 'db-worker',
|
||||
'git-admin': 'git-worker',
|
||||
// Legacy agent IDs (pre-Phase 6)
|
||||
sysadmin_agent: null,
|
||||
db_admin_agent: 'db-worker',
|
||||
git_admin_agent: 'git-worker',
|
||||
coordinator: 'ctrl-worker',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -49,22 +49,22 @@ describe('Control Plane Provisioning', () => {
|
|||
expect(main?.role).toBe('orchestrator');
|
||||
});
|
||||
|
||||
it('DEFAULT_AGENTS contains Sysadmin agent with role "sysadmin_agent"', () => {
|
||||
const agent = DEFAULT_AGENTS.find((a) => a.id === 'sysadmin_agent');
|
||||
it('DEFAULT_AGENTS contains Sysadmin agent with role "sysadmin"', () => {
|
||||
const agent = DEFAULT_AGENTS.find((a) => a.id === 'sysadmin');
|
||||
expect(agent).toBeDefined();
|
||||
expect(agent?.role).toBe('sysadmin_agent');
|
||||
expect(agent?.role).toBe('sysadmin');
|
||||
});
|
||||
|
||||
it('DEFAULT_AGENTS contains DBA agent with role "db_admin_agent"', () => {
|
||||
const agent = DEFAULT_AGENTS.find((a) => a.id === 'db_admin_agent');
|
||||
it('DEFAULT_AGENTS contains DBA agent with role "db-admin"', () => {
|
||||
const agent = DEFAULT_AGENTS.find((a) => a.id === 'db-admin');
|
||||
expect(agent).toBeDefined();
|
||||
expect(agent?.role).toBe('db_admin_agent');
|
||||
expect(agent?.role).toBe('db-admin');
|
||||
});
|
||||
|
||||
it('DEFAULT_AGENTS contains Git Admin agent with role "git_admin_agent"', () => {
|
||||
const agent = DEFAULT_AGENTS.find((a) => a.id === 'git_admin_agent');
|
||||
it('DEFAULT_AGENTS contains Git Admin agent with role "git-admin"', () => {
|
||||
const agent = DEFAULT_AGENTS.find((a) => a.id === 'git-admin');
|
||||
expect(agent).toBeDefined();
|
||||
expect(agent?.role).toBe('git_admin_agent');
|
||||
expect(agent?.role).toBe('git-admin');
|
||||
});
|
||||
|
||||
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_agent')!;
|
||||
const agent = DEFAULT_AGENTS.find((a) => a.id === 'sysadmin')!;
|
||||
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 === 'db_admin_agent')!;
|
||||
const agent = DEFAULT_AGENTS.find((a) => a.id === 'db-admin')!;
|
||||
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_agent')!;
|
||||
const agent = DEFAULT_AGENTS.find((a) => a.id === 'git-admin')!;
|
||||
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_agent')!;
|
||||
const agent = DEFAULT_AGENTS.find((a) => a.id === 'sysadmin')!;
|
||||
expect(agent.budget_allocation_pct).toBe(10);
|
||||
});
|
||||
|
||||
it('DBA gets 5% allocation', () => {
|
||||
const agent = DEFAULT_AGENTS.find((a) => a.id === 'db_admin_agent')!;
|
||||
const agent = DEFAULT_AGENTS.find((a) => a.id === 'db-admin')!;
|
||||
expect(agent.budget_allocation_pct).toBe(5);
|
||||
});
|
||||
|
||||
it('Git Admin gets 5% allocation', () => {
|
||||
const agent = DEFAULT_AGENTS.find((a) => a.id === 'git_admin_agent')!;
|
||||
const agent = DEFAULT_AGENTS.find((a) => a.id === 'git-admin')!;
|
||||
expect(agent.budget_allocation_pct).toBe(5);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
});
|
||||
|
||||
describe('agent routing', () => {
|
||||
it('routes jail messages to sysadmin_agent', async () => {
|
||||
it('routes jail messages to sysadmin', async () => {
|
||||
const pool = makePool();
|
||||
const result = await bridgeTelegramMessage(
|
||||
pool,
|
||||
|
|
@ -140,10 +140,10 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
'tg:test',
|
||||
'Check jail status',
|
||||
);
|
||||
expect(result.assignedTo).toBe('sysadmin_agent');
|
||||
expect(result.assignedTo).toBe('sysadmin');
|
||||
});
|
||||
|
||||
it('routes database messages to db_admin_agent', async () => {
|
||||
it('routes database messages to db-admin', async () => {
|
||||
const pool = makePool();
|
||||
const result = await bridgeTelegramMessage(
|
||||
pool,
|
||||
|
|
@ -151,10 +151,10 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
'tg:test',
|
||||
'backup the database',
|
||||
);
|
||||
expect(result.assignedTo).toBe('db_admin_agent');
|
||||
expect(result.assignedTo).toBe('db-admin');
|
||||
});
|
||||
|
||||
it('routes git messages to git_admin_agent', async () => {
|
||||
it('routes git messages to git-admin', async () => {
|
||||
const pool = makePool();
|
||||
const result = await bridgeTelegramMessage(
|
||||
pool,
|
||||
|
|
@ -162,7 +162,7 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
'tg:test',
|
||||
'check git repo',
|
||||
);
|
||||
expect(result.assignedTo).toBe('git_admin_agent');
|
||||
expect(result.assignedTo).toBe('git-admin');
|
||||
});
|
||||
|
||||
it('routes unrecognised agent messages to orchestrator', async () => {
|
||||
|
|
@ -176,7 +176,7 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
expect(result.assignedTo).toBe('clawdie');
|
||||
});
|
||||
|
||||
it('routes zfs messages to sysadmin_agent', async () => {
|
||||
it('routes zfs messages to sysadmin', async () => {
|
||||
const pool = makePool();
|
||||
const result = await bridgeTelegramMessage(
|
||||
pool,
|
||||
|
|
@ -184,10 +184,10 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
'tg:test',
|
||||
'take a zfs snapshot',
|
||||
);
|
||||
expect(result.assignedTo).toBe('sysadmin_agent');
|
||||
expect(result.assignedTo).toBe('sysadmin');
|
||||
});
|
||||
|
||||
it('routes sql messages to db_admin_agent', async () => {
|
||||
it('routes sql messages to db-admin', async () => {
|
||||
const pool = makePool();
|
||||
const result = await bridgeTelegramMessage(
|
||||
pool,
|
||||
|
|
@ -195,7 +195,7 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
'tg:test',
|
||||
'run this SQL query',
|
||||
);
|
||||
expect(result.assignedTo).toBe('db_admin_agent');
|
||||
expect(result.assignedTo).toBe('db-admin');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -241,7 +241,7 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
'tg:test',
|
||||
'Check jail status',
|
||||
);
|
||||
expect(insertedTasks[0].assigned_to).toBe('sysadmin_agent');
|
||||
expect(insertedTasks[0].assigned_to).toBe('sysadmin');
|
||||
});
|
||||
|
||||
it('always creates a task even without skill match', async () => {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export interface TelegramBridgeResult {
|
|||
|
||||
const ROLE_KEYWORDS: Array<{ role: string; patterns: RegExp[] }> = [
|
||||
{
|
||||
role: 'db_admin_agent',
|
||||
role: 'db-admin',
|
||||
patterns: [
|
||||
/\bdatabase\b/i,
|
||||
/\bpostgres\b/i,
|
||||
|
|
@ -43,7 +43,7 @@ const ROLE_KEYWORDS: Array<{ role: string; patterns: RegExp[] }> = [
|
|||
],
|
||||
},
|
||||
{
|
||||
role: 'git_admin_agent',
|
||||
role: 'git-admin',
|
||||
patterns: [
|
||||
/\bgit\b/i,
|
||||
/\brepo\b/i,
|
||||
|
|
@ -57,7 +57,7 @@ const ROLE_KEYWORDS: Array<{ role: string; patterns: RegExp[] }> = [
|
|||
],
|
||||
},
|
||||
{
|
||||
role: 'sysadmin_agent',
|
||||
role: 'sysadmin',
|
||||
patterns: [
|
||||
/jail/i,
|
||||
/service/i,
|
||||
|
|
|
|||
|
|
@ -51,20 +51,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_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() },
|
||||
{ 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: 'db-admin', role: 'db-admin', 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() },
|
||||
];
|
||||
|
||||
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_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() },
|
||||
{ agent_id: 'sysadmin', daily_tokens: 10000, spent_today: 5000, remaining: 5000, hard_limit_exceeded: false, reset_at: new Date() },
|
||||
{ agent_id: 'db-admin', 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() },
|
||||
];
|
||||
|
||||
const defaultTasks: Task[] = [
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
let activityLog: Array<{ agent_id: string; event_type: string; payload: Record<string, unknown> | null; tokens_used: number | null }> = [];
|
||||
|
|
@ -108,15 +108,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_agent', 'interval_elapsed');
|
||||
const result = await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
|
||||
expect(result.woke).toBe(true);
|
||||
expect(result.agentId).toBe('sysadmin_agent');
|
||||
expect(result.agentId).toBe('sysadmin');
|
||||
});
|
||||
|
||||
it('receives task assignment via tasks query', async () => {
|
||||
const pool = makePool();
|
||||
const config = makeConfig(pool);
|
||||
await runAgentHeartbeat(config, 'sysadmin_agent', 'assignment', 'TASK-001');
|
||||
await runAgentHeartbeat(config, 'sysadmin', 'assignment', 'TASK-001');
|
||||
const taskQuery = vi.mocked(pool.query).mock.calls.find((c) => /FROM tasks/i.test(c[0] as string));
|
||||
expect(taskQuery).toBeDefined();
|
||||
});
|
||||
|
|
@ -124,23 +124,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_agent', 'interval_elapsed');
|
||||
await runAgentHeartbeat(config, 'sysadmin', '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_agent', 'interval_elapsed');
|
||||
const events = activityLog.filter((e) => e.agent_id === 'sysadmin_agent');
|
||||
await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
|
||||
const events = activityLog.filter((e) => e.agent_id === 'sysadmin');
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('session JSONL is updated after completion', async () => {
|
||||
const pool = makePool();
|
||||
const config = makeConfig(pool);
|
||||
await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
|
||||
const sessionFile = path.join(sessionDir, 'sysadmin_agent.jsonl');
|
||||
await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
|
||||
const sessionFile = path.join(sessionDir, 'sysadmin.jsonl');
|
||||
if (fs.existsSync(sessionFile)) {
|
||||
const content = fs.readFileSync(sessionFile, 'utf-8');
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
|
|
@ -150,22 +150,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: 'db_admin_agent', 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', 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, 'db_admin_agent', 'assignment', 'TASK-002');
|
||||
const result = await runAgentHeartbeat(config, 'db-admin', '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, 'db_admin_agent', 'assignment', 'TASK-002');
|
||||
await runAgentHeartbeat(config, 'db-admin', '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] === 'db_admin_agent',
|
||||
/FROM tasks/i.test(c[0] as string) && (c[1] as unknown[])?.[0] === 'db-admin',
|
||||
);
|
||||
expect(taskQuery).toBeDefined();
|
||||
});
|
||||
|
|
@ -173,8 +173,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, 'db_admin_agent', 'assignment', 'TASK-002');
|
||||
const completed = activityLog.filter((e) => e.event_type === 'task_completed' && e.agent_id === 'db_admin_agent');
|
||||
await runAgentHeartbeat(config, 'db-admin', 'assignment', 'TASK-002');
|
||||
const completed = activityLog.filter((e) => e.event_type === 'task_completed' && e.agent_id === 'db-admin');
|
||||
expect(completed.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -182,22 +182,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_agent', daily_tokens: 10000, spent_today: 10000, remaining: 0, hard_limit_exceeded: true, reset_at: new Date() },
|
||||
{ agent_id: 'sysadmin', 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_agent', 'interval_elapsed');
|
||||
const result = await runAgentHeartbeat(config, 'sysadmin', '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_agent', daily_tokens: 10000, spent_today: 10000, remaining: 0, hard_limit_exceeded: true, reset_at: new Date() },
|
||||
{ agent_id: 'sysadmin', 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_agent', 'interval_elapsed');
|
||||
await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
|
||||
const errorEvents = activityLog.filter((e) => e.event_type === 'error');
|
||||
expect(errorEvents.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
|
@ -207,14 +207,14 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
|
|||
it('pi spawn failure posts error event to control plane', async () => {
|
||||
const pool = makePool();
|
||||
const config = makeConfig(pool);
|
||||
const result = await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
|
||||
expect(result.agentId).toBe('sysadmin_agent');
|
||||
const result = await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
|
||||
expect(result.agentId).toBe('sysadmin');
|
||||
});
|
||||
|
||||
it('error event includes action_taken field', async () => {
|
||||
const pool = makePool();
|
||||
const config = makeConfig(pool);
|
||||
await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
|
||||
await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
|
||||
if (activityLog.length > 0) {
|
||||
const hasActionTaken = activityLog.some((e) => {
|
||||
if (e.payload && typeof e.payload === 'object') return 'action_taken' in e.payload;
|
||||
|
|
@ -227,7 +227,7 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
|
|||
it('scheduler does not crash on agent error (continues to next tick)', async () => {
|
||||
const pool = makePool();
|
||||
const config = makeConfig(pool);
|
||||
const result = await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
|
||||
const result = await runAgentHeartbeat(config, 'sysadmin', 'interval_elapsed');
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty('agentId');
|
||||
});
|
||||
|
|
@ -235,20 +235,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_agent', 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', 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_agent', 'telegram', 'TASK-TG1');
|
||||
expect(result.agentId).toBe('sysadmin_agent');
|
||||
const result = await runAgentHeartbeat(config, 'sysadmin', 'telegram', 'TASK-TG1');
|
||||
expect(result.agentId).toBe('sysadmin');
|
||||
});
|
||||
|
||||
it('task completion posts activity event', async () => {
|
||||
const pool = makePool(defaultAgents, defaultBudgets, telegramTasks);
|
||||
const config = makeConfig(pool);
|
||||
await runAgentHeartbeat(config, 'sysadmin_agent', 'telegram', 'TASK-TG1');
|
||||
await runAgentHeartbeat(config, 'sysadmin', 'telegram', 'TASK-TG1');
|
||||
expect(activityLog.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ describe('Skills Discovery', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('skill pattern matching — sysadmin_agent', () => {
|
||||
describe('skill pattern matching — sysadmin', () => {
|
||||
const sysadminSkills: Skill[] = [
|
||||
{
|
||||
name: 'jail-status',
|
||||
|
|
@ -95,9 +95,9 @@ describe('Skills Discovery', () => {
|
|||
).toBe('jail-status');
|
||||
});
|
||||
|
||||
it('"Is sysadmin_agent-db up?" → jail-status', () => {
|
||||
it('"Is sysadmin-db up?" → jail-status', () => {
|
||||
expect(
|
||||
matchTaskToSkill('Is sysadmin_agent-db up?', sysadminSkills).skill!
|
||||
matchTaskToSkill('Is sysadmin-db up?', sysadminSkills).skill!
|
||||
.name,
|
||||
).toBe('jail-status');
|
||||
});
|
||||
|
|
@ -141,7 +141,7 @@ describe('Skills Discovery', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('skill pattern matching — db_admin_agent', () => {
|
||||
describe('skill pattern matching — db-admin', () => {
|
||||
const dbaSkills: Skill[] = [
|
||||
{
|
||||
name: 'db-vacuum',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue