@desplega.ai/agent-swarm 1.10.3 → 1.10.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +76 -0
- package/.github/workflows/{docker-publish.yml → docker-and-deploy.yml} +26 -1
- package/Dockerfile +6 -3
- package/README.md +8 -0
- package/assets/agent-swarm.mp4 +0 -0
- package/biome.json +4 -1
- package/deploy/prod-db.ts +1 -1
- package/package.json +2 -2
- package/pyproject.toml +9 -0
- package/src/be/db.ts +32 -2
- package/src/claude.ts +8 -8
- package/src/commands/runner.ts +123 -16
- package/src/http.ts +69 -3
- package/src/server.ts +2 -1
- package/src/slack/handlers.ts +0 -1
- package/src/tests/rest-api.test.ts +555 -0
- package/src/tests/runner-polling-api.test.ts +12 -12
- package/src/tools/poll-task.ts +0 -1
- package/thoughts/shared/plans/2025-12-23-worker-lead-spawn-triggers.md +568 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
push:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
lint-and-typecheck:
|
|
14
|
+
name: Lint and Type Check
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- name: Checkout repository
|
|
19
|
+
uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Setup Bun
|
|
22
|
+
uses: oven-sh/setup-bun@v2
|
|
23
|
+
with:
|
|
24
|
+
bun-version: latest
|
|
25
|
+
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: bun install --frozen-lockfile
|
|
28
|
+
|
|
29
|
+
- name: Run Biome linter
|
|
30
|
+
run: bun run lint
|
|
31
|
+
|
|
32
|
+
- name: Run TypeScript type check
|
|
33
|
+
run: bun run tsc:check
|
|
34
|
+
|
|
35
|
+
test:
|
|
36
|
+
name: Run Tests
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
|
|
39
|
+
steps:
|
|
40
|
+
- name: Checkout repository
|
|
41
|
+
uses: actions/checkout@v4
|
|
42
|
+
|
|
43
|
+
- name: Setup Bun
|
|
44
|
+
uses: oven-sh/setup-bun@v2
|
|
45
|
+
with:
|
|
46
|
+
bun-version: latest
|
|
47
|
+
|
|
48
|
+
- name: Install dependencies
|
|
49
|
+
run: bun install --frozen-lockfile
|
|
50
|
+
|
|
51
|
+
- name: Run tests
|
|
52
|
+
run: bun test
|
|
53
|
+
|
|
54
|
+
docker-build:
|
|
55
|
+
name: Docker Build Test
|
|
56
|
+
runs-on: ubuntu-latest
|
|
57
|
+
if: github.event_name == 'pull_request'
|
|
58
|
+
|
|
59
|
+
strategy:
|
|
60
|
+
matrix:
|
|
61
|
+
dockerfile: [Dockerfile, Dockerfile.worker]
|
|
62
|
+
|
|
63
|
+
steps:
|
|
64
|
+
- name: Checkout repository
|
|
65
|
+
uses: actions/checkout@v4
|
|
66
|
+
|
|
67
|
+
- name: Set up Docker Buildx
|
|
68
|
+
uses: docker/setup-buildx-action@v3
|
|
69
|
+
|
|
70
|
+
- name: Build Docker image
|
|
71
|
+
uses: docker/build-push-action@v5
|
|
72
|
+
with:
|
|
73
|
+
context: .
|
|
74
|
+
file: ./${{ matrix.dockerfile }}
|
|
75
|
+
push: false
|
|
76
|
+
tags: test-image:latest
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
name: Docker Build
|
|
1
|
+
name: Docker Build + Publish + Deploy
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
@@ -50,6 +50,7 @@ jobs:
|
|
|
50
50
|
push: true
|
|
51
51
|
tags: ${{ steps.meta.outputs.tags }}
|
|
52
52
|
labels: ${{ steps.meta.outputs.labels }}
|
|
53
|
+
no-cache: true
|
|
53
54
|
|
|
54
55
|
build-and-push-worker:
|
|
55
56
|
runs-on: ubuntu-latest
|
|
@@ -90,3 +91,27 @@ jobs:
|
|
|
90
91
|
push: true
|
|
91
92
|
tags: ${{ steps.meta.outputs.tags }}
|
|
92
93
|
labels: ${{ steps.meta.outputs.labels }}
|
|
94
|
+
no-cache: true
|
|
95
|
+
|
|
96
|
+
# deploy:
|
|
97
|
+
# name: Deploy
|
|
98
|
+
# runs-on: ubuntu-latest
|
|
99
|
+
#
|
|
100
|
+
# needs:
|
|
101
|
+
# - build-and-push-server
|
|
102
|
+
# - build-and-push-worker
|
|
103
|
+
#
|
|
104
|
+
# steps:
|
|
105
|
+
# - name: Checkout
|
|
106
|
+
# id: checkout
|
|
107
|
+
# uses: actions/checkout@v4
|
|
108
|
+
#
|
|
109
|
+
# - name: Deploy Agent Swarm
|
|
110
|
+
# uses: tarasyarema/dokploy-deploy-action@main
|
|
111
|
+
# with:
|
|
112
|
+
# application_id: h4or-knfw2HWkTzjLmrn8
|
|
113
|
+
# application_name: agent-swarm-nrz8v0
|
|
114
|
+
# dokploy_url: https://app.dokploy.com
|
|
115
|
+
# auth_token: ${{ secrets.DOKPLOY_TOKEN }}
|
|
116
|
+
# wait_for_completion: true
|
|
117
|
+
# restart: true
|
package/Dockerfile
CHANGED
|
@@ -35,10 +35,13 @@ RUN chmod +x /usr/local/bin/agent-swarm-api
|
|
|
35
35
|
# Copy package.json for version info
|
|
36
36
|
COPY package.json ./
|
|
37
37
|
|
|
38
|
-
#
|
|
39
|
-
|
|
38
|
+
# Create data directory for SQLite (WAL mode needs .sqlite, .sqlite-wal, .sqlite-shm on same filesystem)
|
|
39
|
+
RUN mkdir -p /app/data
|
|
40
40
|
|
|
41
41
|
ENV PORT=3013
|
|
42
|
+
ENV DATABASE_PATH=/app/data/agent-swarm-db.sqlite
|
|
43
|
+
|
|
44
|
+
VOLUME /app/data
|
|
42
45
|
|
|
43
46
|
EXPOSE 3013
|
|
44
47
|
|
|
@@ -49,6 +52,6 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
|
49
52
|
# Print version on startup and run the server
|
|
50
53
|
CMD echo "=== Agent Swarm API v$(cat /app/package.json | grep '\"version\"' | cut -d'"' -f4) ===" && \
|
|
51
54
|
echo "Port: $PORT" && \
|
|
52
|
-
echo "Database:
|
|
55
|
+
echo "Database: $DATABASE_PATH" && \
|
|
53
56
|
echo "==============================" && \
|
|
54
57
|
exec /usr/local/bin/agent-swarm-api
|
package/README.md
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
<img src="assets/agent-swarm.png" alt="Agent Swarm" width="800">
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
|
+
https://github.com/user-attachments/assets/bd308567-d21e-44a5-87ec-d25aeb1de3d3
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://discord.gg/3XtmPdXm">
|
|
11
|
+
<img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord">
|
|
12
|
+
</a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
7
15
|
> Agent orchestration layer MCP for Claude Code, Codex, Gemini CLI, and more!
|
|
8
16
|
|
|
9
17
|
## Table of Contents
|
|
Binary file
|
package/biome.json
CHANGED
package/deploy/prod-db.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { $ } from "bun";
|
|
4
4
|
import * as readline from "node:readline";
|
|
5
5
|
|
|
6
|
-
const DB_PATH = "/var/lib/docker/volumes/agent-swarm-
|
|
6
|
+
const DB_PATH = "/var/lib/docker/volumes/agent-swarm-nrz8v0_swarm_db/_data/agent-swarm-db.sqlite";
|
|
7
7
|
const SSH_HOST = process.argv[2] || "swarm";
|
|
8
8
|
|
|
9
9
|
console.log(`Connected to ${SSH_HOST}:${DB_PATH}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desplega.ai/agent-swarm",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.8",
|
|
4
4
|
"description": "Agent orchestration layer MCP for Claude Code, Codex, Gemini CLI, and more!",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "desplega.ai <contact@desplega.ai>",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"docs:mcp": "bun scripts/generate-mcp-docs.ts"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@biomejs/biome": "^2.3.
|
|
64
|
+
"@biomejs/biome": "^2.3.10",
|
|
65
65
|
"@types/bun": "latest"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
package/pyproject.toml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Minimal pyproject.toml to satisfy GitHub Actions setup-python cache requirement
|
|
2
|
+
# This file is a workaround for: https://github.com/actions/setup-python/issues/XXX
|
|
3
|
+
# The dokploy-deploy-action uses setup-python with pip caching, which requires
|
|
4
|
+
# a pyproject.toml or requirements.txt even though this is a Node.js/TypeScript project.
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "agent-swarm-deploy-workaround"
|
|
8
|
+
version = "0.0.0"
|
|
9
|
+
description = "Workaround file for GitHub Actions deployment"
|
package/src/be/db.ts
CHANGED
|
@@ -24,6 +24,8 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
db = new Database(dbPath, { create: true });
|
|
27
|
+
console.log(`Database initialized at ${dbPath}`);
|
|
28
|
+
|
|
27
29
|
db.run("PRAGMA journal_mode = WAL;");
|
|
28
30
|
db.run("PRAGMA foreign_keys = ON;");
|
|
29
31
|
|
|
@@ -333,9 +335,9 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
|
|
|
333
335
|
return db;
|
|
334
336
|
}
|
|
335
337
|
|
|
336
|
-
export function getDb(): Database {
|
|
338
|
+
export function getDb(path?: string): Database {
|
|
337
339
|
if (!db) {
|
|
338
|
-
return initDb();
|
|
340
|
+
return initDb(path ?? process.env.DATABASE_PATH);
|
|
339
341
|
}
|
|
340
342
|
return db;
|
|
341
343
|
}
|
|
@@ -728,6 +730,34 @@ export function getCompletedSlackTasks(): AgentTask[] {
|
|
|
728
730
|
.map(rowToAgentTask);
|
|
729
731
|
}
|
|
730
732
|
|
|
733
|
+
/**
|
|
734
|
+
* Get tasks that were recently finished (completed/failed) by workers (non-lead agents).
|
|
735
|
+
* Used by leads to know when workers complete tasks.
|
|
736
|
+
*/
|
|
737
|
+
export function getRecentlyFinishedWorkerTasks(since?: string): AgentTask[] {
|
|
738
|
+
const query = since
|
|
739
|
+
? `SELECT t.* FROM agent_tasks t
|
|
740
|
+
LEFT JOIN agents a ON t.agentId = a.id
|
|
741
|
+
WHERE t.status IN ('completed', 'failed')
|
|
742
|
+
AND t.finishedAt > ?
|
|
743
|
+
AND (a.isLead = 0 OR a.isLead IS NULL)
|
|
744
|
+
ORDER BY t.finishedAt DESC
|
|
745
|
+
LIMIT 50`
|
|
746
|
+
: `SELECT t.* FROM agent_tasks t
|
|
747
|
+
LEFT JOIN agents a ON t.agentId = a.id
|
|
748
|
+
WHERE t.status IN ('completed', 'failed')
|
|
749
|
+
AND t.finishedAt IS NOT NULL
|
|
750
|
+
AND (a.isLead = 0 OR a.isLead IS NULL)
|
|
751
|
+
ORDER BY t.finishedAt DESC
|
|
752
|
+
LIMIT 10`;
|
|
753
|
+
|
|
754
|
+
if (since) {
|
|
755
|
+
return getDb().prepare<AgentTaskRow, [string]>(query).all(since).map(rowToAgentTask);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return getDb().prepare<AgentTaskRow, []>(query).all().map(rowToAgentTask);
|
|
759
|
+
}
|
|
760
|
+
|
|
731
761
|
export function getInProgressSlackTasks(): AgentTask[] {
|
|
732
762
|
return getDb()
|
|
733
763
|
.prepare<AgentTaskRow, []>(
|
package/src/claude.ts
CHANGED
|
@@ -7,23 +7,23 @@ type RunCladeOptions = {
|
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
export const runClaude = async (opts: RunCladeOptions) => {
|
|
10
|
-
const
|
|
10
|
+
const Cmd = ["claude"];
|
|
11
11
|
|
|
12
12
|
if (opts.headless) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
Cmd.push("--verbose");
|
|
14
|
+
Cmd.push("--output-format");
|
|
15
|
+
Cmd.push("stream-json");
|
|
16
|
+
Cmd.push("-p");
|
|
17
|
+
Cmd.push(opts.msg);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
if (opts.additionalArgs && opts.additionalArgs.length > 0) {
|
|
21
|
-
|
|
21
|
+
Cmd.push(...opts.additionalArgs);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
console.debug(`[debug] Running in ${opts.headless ? "headless" : "normal"} mode`);
|
|
25
25
|
|
|
26
|
-
const proc = Bun.spawn(
|
|
26
|
+
const proc = Bun.spawn(Cmd, {
|
|
27
27
|
env: process.env,
|
|
28
28
|
stdout: opts.headless ? "pipe" : "inherit",
|
|
29
29
|
stdin: opts.headless ? undefined : "inherit",
|
package/src/commands/runner.ts
CHANGED
|
@@ -13,10 +13,60 @@ async function savePm2State(role: string): Promise<void> {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** API configuration for ping/close */
|
|
17
|
+
interface ApiConfig {
|
|
18
|
+
apiUrl: string;
|
|
19
|
+
apiKey: string;
|
|
20
|
+
agentId: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Ping the server to indicate activity and update status */
|
|
24
|
+
async function pingServer(config: ApiConfig, _role: string): Promise<void> {
|
|
25
|
+
const headers: Record<string, string> = {
|
|
26
|
+
"X-Agent-ID": config.agentId,
|
|
27
|
+
};
|
|
28
|
+
if (config.apiKey) {
|
|
29
|
+
headers.Authorization = `Bearer ${config.apiKey}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await fetch(`${config.apiUrl}/ping`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers,
|
|
36
|
+
});
|
|
37
|
+
} catch {
|
|
38
|
+
// Silently fail - server might not be running
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Mark agent as offline on shutdown */
|
|
43
|
+
async function closeAgent(config: ApiConfig, role: string): Promise<void> {
|
|
44
|
+
const headers: Record<string, string> = {
|
|
45
|
+
"X-Agent-ID": config.agentId,
|
|
46
|
+
};
|
|
47
|
+
if (config.apiKey) {
|
|
48
|
+
headers.Authorization = `Bearer ${config.apiKey}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
console.log(`[${role}] Marking agent as offline...`);
|
|
53
|
+
await fetch(`${config.apiUrl}/close`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers,
|
|
56
|
+
});
|
|
57
|
+
console.log(`[${role}] Agent marked as offline`);
|
|
58
|
+
} catch {
|
|
59
|
+
// Silently fail - server might not be running
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
16
63
|
/** Setup signal handlers for graceful shutdown */
|
|
17
|
-
function setupShutdownHandlers(role: string): void {
|
|
64
|
+
function setupShutdownHandlers(role: string, apiConfig?: ApiConfig): void {
|
|
18
65
|
const shutdown = async (signal: string) => {
|
|
19
66
|
console.log(`\n[${role}] Received ${signal}, shutting down...`);
|
|
67
|
+
if (apiConfig) {
|
|
68
|
+
await closeAgent(apiConfig, role);
|
|
69
|
+
}
|
|
20
70
|
await savePm2State(role);
|
|
21
71
|
process.exit(0);
|
|
22
72
|
};
|
|
@@ -122,11 +172,22 @@ async function flushLogBuffer(
|
|
|
122
172
|
|
|
123
173
|
/** Trigger types returned by the poll API */
|
|
124
174
|
interface Trigger {
|
|
125
|
-
type:
|
|
175
|
+
type:
|
|
176
|
+
| "task_assigned"
|
|
177
|
+
| "task_offered"
|
|
178
|
+
| "unread_mentions"
|
|
179
|
+
| "pool_tasks_available"
|
|
180
|
+
| "tasks_finished";
|
|
126
181
|
taskId?: string;
|
|
127
182
|
task?: unknown;
|
|
128
183
|
mentionsCount?: number;
|
|
129
184
|
count?: number;
|
|
185
|
+
tasks?: Array<{
|
|
186
|
+
id: string;
|
|
187
|
+
agentId?: string;
|
|
188
|
+
task: string;
|
|
189
|
+
status: string;
|
|
190
|
+
}>;
|
|
130
191
|
}
|
|
131
192
|
|
|
132
193
|
/** Options for polling */
|
|
@@ -136,6 +197,7 @@ interface PollOptions {
|
|
|
136
197
|
agentId: string;
|
|
137
198
|
pollInterval: number;
|
|
138
199
|
pollTimeout: number;
|
|
200
|
+
since?: string; // Optional: for filtering finished tasks
|
|
139
201
|
}
|
|
140
202
|
|
|
141
203
|
/** Register agent via HTTP API */
|
|
@@ -183,7 +245,13 @@ async function pollForTrigger(opts: PollOptions): Promise<Trigger | null> {
|
|
|
183
245
|
|
|
184
246
|
while (Date.now() - startTime < opts.pollTimeout) {
|
|
185
247
|
try {
|
|
186
|
-
|
|
248
|
+
// Build URL with optional since parameter
|
|
249
|
+
let url = `${opts.apiUrl}/api/poll`;
|
|
250
|
+
if (opts.since) {
|
|
251
|
+
url += `?since=${encodeURIComponent(opts.since)}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const response = await fetch(url, {
|
|
187
255
|
method: "GET",
|
|
188
256
|
headers,
|
|
189
257
|
});
|
|
@@ -224,8 +292,23 @@ function buildPromptForTrigger(trigger: Trigger, defaultPrompt: string): string
|
|
|
224
292
|
return "/swarm-chat";
|
|
225
293
|
|
|
226
294
|
case "pool_tasks_available":
|
|
227
|
-
//
|
|
228
|
-
|
|
295
|
+
// Worker: claim a task from the pool
|
|
296
|
+
// Include the count so worker knows there are tasks available
|
|
297
|
+
return `There are ${trigger.count} unassigned task(s) available in the pool. Use get-tasks with unassigned: true to see them, then use task-action with action: "claim" to claim one. The claim is first-come-first-serve, so if your claim fails, try another task.`;
|
|
298
|
+
|
|
299
|
+
case "tasks_finished":
|
|
300
|
+
// Lead: simple notification about finished tasks
|
|
301
|
+
if (trigger.tasks && Array.isArray(trigger.tasks) && trigger.tasks.length > 0) {
|
|
302
|
+
const taskSummaries = trigger.tasks
|
|
303
|
+
.map((t) => {
|
|
304
|
+
const status = t.status === "completed" ? "completed" : "failed";
|
|
305
|
+
const agentName = t.agentId ? `Agent ${t.agentId.slice(0, 8)}` : "Unknown agent";
|
|
306
|
+
return `- ${agentName} ${status} task "${t.task?.slice(0, 50)}..." (ID: ${t.id})`;
|
|
307
|
+
})
|
|
308
|
+
.join("\n");
|
|
309
|
+
return `Workers have finished ${trigger.count} task(s):\n${taskSummaries}\n\nReview these results and decide if any follow-up actions are needed.`;
|
|
310
|
+
}
|
|
311
|
+
return `Workers have finished ${trigger.count} task(s). Use get-tasks with status "completed" or "failed" to review them.`;
|
|
229
312
|
|
|
230
313
|
default:
|
|
231
314
|
return defaultPrompt;
|
|
@@ -234,7 +317,7 @@ function buildPromptForTrigger(trigger: Trigger, defaultPrompt: string): string
|
|
|
234
317
|
|
|
235
318
|
async function runClaudeIteration(opts: RunClaudeIterationOptions): Promise<number> {
|
|
236
319
|
const { role } = opts;
|
|
237
|
-
const
|
|
320
|
+
const Cmd = [
|
|
238
321
|
"claude",
|
|
239
322
|
"--verbose",
|
|
240
323
|
"--output-format",
|
|
@@ -248,11 +331,11 @@ async function runClaudeIteration(opts: RunClaudeIterationOptions): Promise<numb
|
|
|
248
331
|
];
|
|
249
332
|
|
|
250
333
|
if (opts.additionalArgs && opts.additionalArgs.length > 0) {
|
|
251
|
-
|
|
334
|
+
Cmd.push(...opts.additionalArgs);
|
|
252
335
|
}
|
|
253
336
|
|
|
254
337
|
if (opts.systemPrompt) {
|
|
255
|
-
|
|
338
|
+
Cmd.push("--append-system-prompt", opts.systemPrompt);
|
|
256
339
|
}
|
|
257
340
|
|
|
258
341
|
console.log(`\x1b[2m[${role}]\x1b[0m \x1b[36m▸\x1b[0m Starting Claude (PID will follow)`);
|
|
@@ -260,7 +343,7 @@ async function runClaudeIteration(opts: RunClaudeIterationOptions): Promise<numb
|
|
|
260
343
|
const logFileHandle = Bun.file(opts.logFile).writer();
|
|
261
344
|
let stderrOutput = "";
|
|
262
345
|
|
|
263
|
-
const proc = Bun.spawn(
|
|
346
|
+
const proc = Bun.spawn(Cmd, {
|
|
264
347
|
env: process.env,
|
|
265
348
|
stdout: "pipe",
|
|
266
349
|
stderr: "pipe",
|
|
@@ -355,9 +438,6 @@ async function runClaudeIteration(opts: RunClaudeIterationOptions): Promise<numb
|
|
|
355
438
|
export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
356
439
|
const { role, defaultPrompt, metadataType } = config;
|
|
357
440
|
|
|
358
|
-
// Setup graceful shutdown handlers (saves PM2 state on Ctrl+C)
|
|
359
|
-
setupShutdownHandlers(role);
|
|
360
|
-
|
|
361
441
|
const sessionId = process.env.SESSION_ID || crypto.randomUUID().slice(0, 8);
|
|
362
442
|
const baseLogDir = opts.logsDir || process.env.LOG_DIR || "/logs";
|
|
363
443
|
const logDir = `${baseLogDir}/${sessionId}`;
|
|
@@ -430,8 +510,8 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
430
510
|
const apiKey = process.env.API_KEY || "";
|
|
431
511
|
|
|
432
512
|
// Constants for polling
|
|
433
|
-
const
|
|
434
|
-
const
|
|
513
|
+
const PollIntervalMs = 2000; // 2 seconds between polls
|
|
514
|
+
const PollTimeoutMs = 60000; // 1 minute timeout before retrying
|
|
435
515
|
|
|
436
516
|
let iteration = 0;
|
|
437
517
|
|
|
@@ -439,6 +519,12 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
439
519
|
// NEW: Runner-level polling mode
|
|
440
520
|
console.log(`[${role}] Mode: runner-level polling (use --ai-loop for AI-based polling)`);
|
|
441
521
|
|
|
522
|
+
// Create API config for ping/close
|
|
523
|
+
const apiConfig: ApiConfig = { apiUrl, apiKey, agentId };
|
|
524
|
+
|
|
525
|
+
// Setup graceful shutdown handlers with API config for close on exit
|
|
526
|
+
setupShutdownHandlers(role, apiConfig);
|
|
527
|
+
|
|
442
528
|
// Register agent before starting
|
|
443
529
|
const agentName = process.env.AGENT_NAME || `${role}-${agentId.slice(0, 8)}`;
|
|
444
530
|
try {
|
|
@@ -456,15 +542,22 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
456
542
|
process.exit(1);
|
|
457
543
|
}
|
|
458
544
|
|
|
545
|
+
// Track last finished task check for leads (to avoid re-processing)
|
|
546
|
+
let lastFinishedTaskCheck: string | undefined;
|
|
547
|
+
|
|
459
548
|
while (true) {
|
|
549
|
+
// Ping server on each iteration to keep status updated
|
|
550
|
+
await pingServer(apiConfig, role);
|
|
551
|
+
|
|
460
552
|
console.log(`\n[${role}] Polling for triggers...`);
|
|
461
553
|
|
|
462
554
|
const trigger = await pollForTrigger({
|
|
463
555
|
apiUrl,
|
|
464
556
|
apiKey,
|
|
465
557
|
agentId,
|
|
466
|
-
pollInterval:
|
|
467
|
-
pollTimeout:
|
|
558
|
+
pollInterval: PollIntervalMs,
|
|
559
|
+
pollTimeout: PollTimeoutMs,
|
|
560
|
+
since: lastFinishedTaskCheck,
|
|
468
561
|
});
|
|
469
562
|
|
|
470
563
|
if (!trigger) {
|
|
@@ -472,6 +565,11 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
472
565
|
continue;
|
|
473
566
|
}
|
|
474
567
|
|
|
568
|
+
// After getting a tasks_finished trigger, update the timestamp
|
|
569
|
+
if (trigger.type === "tasks_finished") {
|
|
570
|
+
lastFinishedTaskCheck = new Date().toISOString();
|
|
571
|
+
}
|
|
572
|
+
|
|
475
573
|
console.log(`[${role}] Trigger received: ${trigger.type}`);
|
|
476
574
|
|
|
477
575
|
// Build prompt based on trigger
|
|
@@ -540,7 +638,16 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
540
638
|
// Original AI-loop mode (existing behavior)
|
|
541
639
|
console.log(`[${role}] Mode: AI-based polling (legacy)`);
|
|
542
640
|
|
|
641
|
+
// Create API config for ping/close
|
|
642
|
+
const apiConfig: ApiConfig = { apiUrl, apiKey, agentId };
|
|
643
|
+
|
|
644
|
+
// Setup graceful shutdown handlers with API config for close on exit
|
|
645
|
+
setupShutdownHandlers(role, apiConfig);
|
|
646
|
+
|
|
543
647
|
while (true) {
|
|
648
|
+
// Ping server on each iteration to keep status updated
|
|
649
|
+
await pingServer(apiConfig, role);
|
|
650
|
+
|
|
544
651
|
iteration++;
|
|
545
652
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
546
653
|
const logFile = `${logDir}/${timestamp}.jsonl`;
|
package/src/http.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
closeDb,
|
|
13
13
|
createAgent,
|
|
14
14
|
createSessionLogs,
|
|
15
|
+
createTaskExtended,
|
|
15
16
|
getAgentById,
|
|
16
17
|
getAgentWithTasks,
|
|
17
18
|
getAllAgents,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
getLogsByTaskId,
|
|
29
30
|
getOfferedTasksForAgent,
|
|
30
31
|
getPendingTaskForAgent,
|
|
32
|
+
getRecentlyFinishedWorkerTasks,
|
|
31
33
|
getServicesByAgentId,
|
|
32
34
|
getSessionLogsByTaskId,
|
|
33
35
|
getTaskById,
|
|
@@ -284,6 +286,10 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
284
286
|
return;
|
|
285
287
|
}
|
|
286
288
|
|
|
289
|
+
// Get optional 'since' parameter for finished tasks
|
|
290
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
291
|
+
const since = queryParams.get("since") || undefined;
|
|
292
|
+
|
|
287
293
|
// Use transaction for consistent reads across all trigger checks
|
|
288
294
|
const result = getDb().transaction(() => {
|
|
289
295
|
const agent = getAgentById(myAgentId);
|
|
@@ -291,7 +297,7 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
291
297
|
return { error: "Agent not found", status: 404 };
|
|
292
298
|
}
|
|
293
299
|
|
|
294
|
-
// Check for offered tasks first (highest priority)
|
|
300
|
+
// Check for offered tasks first (highest priority for both workers and leads)
|
|
295
301
|
const offeredTasks = getOfferedTasksForAgent(myAgentId);
|
|
296
302
|
const firstOfferedTask = offeredTasks[0];
|
|
297
303
|
if (firstOfferedTask) {
|
|
@@ -316,8 +322,10 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
316
322
|
};
|
|
317
323
|
}
|
|
318
324
|
|
|
319
|
-
// For lead agents, check for unread mentions
|
|
320
325
|
if (agent.isLead) {
|
|
326
|
+
// === LEAD-SPECIFIC TRIGGERS ===
|
|
327
|
+
|
|
328
|
+
// Check for unread mentions
|
|
321
329
|
const inbox = getInboxSummary(myAgentId);
|
|
322
330
|
if (inbox.mentionsCount > 0) {
|
|
323
331
|
return {
|
|
@@ -328,7 +336,21 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
328
336
|
};
|
|
329
337
|
}
|
|
330
338
|
|
|
331
|
-
// Check for
|
|
339
|
+
// Check for recently finished worker tasks
|
|
340
|
+
const finishedTasks = getRecentlyFinishedWorkerTasks(since);
|
|
341
|
+
if (finishedTasks.length > 0) {
|
|
342
|
+
return {
|
|
343
|
+
trigger: {
|
|
344
|
+
type: "tasks_finished",
|
|
345
|
+
count: finishedTasks.length,
|
|
346
|
+
tasks: finishedTasks,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
// === WORKER-SPECIFIC TRIGGERS ===
|
|
352
|
+
|
|
353
|
+
// Check for unassigned tasks in pool (workers can claim)
|
|
332
354
|
const unassignedCount = getUnassignedTasksCount();
|
|
333
355
|
if (unassignedCount > 0) {
|
|
334
356
|
return {
|
|
@@ -524,6 +546,50 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
524
546
|
return;
|
|
525
547
|
}
|
|
526
548
|
|
|
549
|
+
// POST /api/tasks - Create a new task
|
|
550
|
+
if (
|
|
551
|
+
req.method === "POST" &&
|
|
552
|
+
pathSegments[0] === "api" &&
|
|
553
|
+
pathSegments[1] === "tasks" &&
|
|
554
|
+
!pathSegments[2]
|
|
555
|
+
) {
|
|
556
|
+
// Parse request body
|
|
557
|
+
const chunks: Buffer[] = [];
|
|
558
|
+
for await (const chunk of req) {
|
|
559
|
+
chunks.push(chunk);
|
|
560
|
+
}
|
|
561
|
+
const body = JSON.parse(Buffer.concat(chunks).toString());
|
|
562
|
+
|
|
563
|
+
// Validate required fields
|
|
564
|
+
if (!body.task || typeof body.task !== "string") {
|
|
565
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
566
|
+
res.end(JSON.stringify({ error: "Missing or invalid 'task' field" }));
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
// Create task with provided options
|
|
572
|
+
const task = createTaskExtended(body.task, {
|
|
573
|
+
agentId: body.agentId || undefined,
|
|
574
|
+
creatorAgentId: myAgentId || undefined,
|
|
575
|
+
taskType: body.taskType || undefined,
|
|
576
|
+
tags: body.tags || undefined,
|
|
577
|
+
priority: body.priority || 50,
|
|
578
|
+
dependsOn: body.dependsOn || undefined,
|
|
579
|
+
offeredTo: body.offeredTo || undefined,
|
|
580
|
+
source: body.source || "api",
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
res.writeHead(201, { "Content-Type": "application/json" });
|
|
584
|
+
res.end(JSON.stringify(task));
|
|
585
|
+
} catch (error) {
|
|
586
|
+
console.error("[HTTP] Failed to create task:", error);
|
|
587
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
588
|
+
res.end(JSON.stringify({ error: "Failed to create task" }));
|
|
589
|
+
}
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
527
593
|
// GET /api/tasks/:id - Get single task with logs
|
|
528
594
|
if (
|
|
529
595
|
req.method === "GET" &&
|
package/src/server.ts
CHANGED
|
@@ -41,7 +41,8 @@ export function getEnabledCapabilities(): string[] {
|
|
|
41
41
|
|
|
42
42
|
export function createServer() {
|
|
43
43
|
// Initialize database with WAL mode
|
|
44
|
-
|
|
44
|
+
// Uses DATABASE_PATH env var for Docker volume compatibility (WAL needs .sqlite, .sqlite-wal, .sqlite-shm on same filesystem)
|
|
45
|
+
initDb(process.env.DATABASE_PATH);
|
|
45
46
|
|
|
46
47
|
const server = new McpServer(
|
|
47
48
|
{
|