@nbardy/oompa 0.5.1 → 0.7.0
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/README.md +26 -16
- package/agentnet/src/agentnet/agent.clj +24 -1
- package/agentnet/src/agentnet/cli.clj +165 -38
- package/agentnet/src/agentnet/orchestrator.clj +1 -1
- package/agentnet/src/agentnet/runs.clj +165 -0
- package/agentnet/src/agentnet/schema.clj +4 -4
- package/agentnet/src/agentnet/tasks.clj +1 -0
- package/agentnet/src/agentnet/worker.clj +578 -299
- package/config/prompts/_task_header.md +8 -1
- package/oompa.example.json +8 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ This repo has a fleshed out version of the idea. The oompa loompas are organized
|
|
|
39
39
|
|
|
40
40
|
- **Different worker types** — small models for fast execution, big models for planning
|
|
41
41
|
- **Separate review model** — use a smart model to check work before merging
|
|
42
|
-
- **Mixed harnesses** — combine Claude and
|
|
42
|
+
- **Mixed harnesses** — combine Claude, Codex, and Opencode workers in one swarm
|
|
43
43
|
- **Self-directed tasks** — workers create and claim tasks from shared folders
|
|
44
44
|
|
|
45
45
|
### Architecture
|
|
@@ -84,20 +84,22 @@ This repo has a fleshed out version of the idea. The oompa loompas are organized
|
|
|
84
84
|
{
|
|
85
85
|
"workers": [
|
|
86
86
|
{"model": "claude:opus", "prompt": ["config/prompts/planner.md"], "iterations": 5, "count": 1},
|
|
87
|
-
{"model": "codex:gpt-5.3-codex:medium", "prompt": ["config/prompts/executor.md"], "iterations": 10, "count":
|
|
87
|
+
{"model": "codex:gpt-5.3-codex:medium", "prompt": ["config/prompts/executor.md"], "iterations": 10, "count": 2, "can_plan": false},
|
|
88
|
+
{"model": "opencode:openai/gpt-5", "prompt": ["config/prompts/executor.md"], "iterations": 10, "count": 1, "can_plan": false}
|
|
88
89
|
]
|
|
89
90
|
}
|
|
90
91
|
```
|
|
91
92
|
|
|
92
93
|
This spawns:
|
|
93
94
|
- **1 planner** (opus) — reads spec, explores codebase, creates/refines tasks
|
|
94
|
-
- **
|
|
95
|
+
- **2 codex executors** (gpt-5.3-codex, medium reasoning) — claims and executes tasks fast
|
|
96
|
+
- **1 opencode executor** (openai/gpt-5) — same task loop via `opencode run`
|
|
95
97
|
|
|
96
98
|
#### Worker fields
|
|
97
99
|
|
|
98
100
|
| Field | Required | Description |
|
|
99
101
|
|-------|----------|-------------|
|
|
100
|
-
| `model` | yes | `harness:model` or `harness:model:reasoning` (e.g. `codex:gpt-5.3-codex:medium`, `claude:opus`) |
|
|
102
|
+
| `model` | yes | `harness:model` or `harness:model:reasoning` (e.g. `codex:gpt-5.3-codex:medium`, `claude:opus`, `opencode:openai/gpt-5`) |
|
|
101
103
|
| `prompt` | no | String or array of paths — concatenated into one prompt |
|
|
102
104
|
| `iterations` | no | Max iterations per worker (default: 10) |
|
|
103
105
|
| `count` | no | Number of workers with this config (default: 1) |
|
|
@@ -111,7 +113,7 @@ This spawns:
|
|
|
111
113
|
{
|
|
112
114
|
"workers": [
|
|
113
115
|
{"model": "claude:opus-4.5", "prompt": ["prompts/base.md", "prompts/architect.md"], "count": 1},
|
|
114
|
-
{"model": "
|
|
116
|
+
{"model": "opencode:openai/gpt-5-mini", "prompt": ["prompts/base.md", "prompts/frontend.md"], "count": 2},
|
|
115
117
|
{"model": "codex:codex-5.2-mini", "prompt": ["prompts/base.md", "prompts/backend.md"], "count": 2}
|
|
116
118
|
]
|
|
117
119
|
}
|
|
@@ -202,21 +204,28 @@ oompa help # Show all commands
|
|
|
202
204
|
|
|
203
205
|
`./swarm.bb ...` works the same when running from a source checkout.
|
|
204
206
|
|
|
207
|
+
## Opencode Harness
|
|
208
|
+
|
|
209
|
+
`opencode` workers use one-shot `opencode run --format json` calls with the same worker prompt tagging:
|
|
210
|
+
|
|
211
|
+
- First prompt line still starts with `[oompa:<swarmId>:<workerId>]`
|
|
212
|
+
- `-m/--model` is passed when a worker model is configured
|
|
213
|
+
- First iteration starts without `--session`; the worker captures `sessionID` from that exact run output
|
|
214
|
+
- On resumed iterations, workers pass `-s/--session <captured-id> --continue`
|
|
215
|
+
- Oompa does not call `opencode session list` to guess a "latest" session
|
|
216
|
+
- Worker completion markers (`COMPLETE_AND_READY_FOR_MERGE`, `__DONE__`) are evaluated from extracted text events, preserving existing done/merge behavior
|
|
217
|
+
- Optional attach mode: set `OOMPA_OPENCODE_ATTACH` (or `OPENCODE_ATTACH`) to add `--attach <url>`
|
|
218
|
+
|
|
205
219
|
## Worker Conversation Persistence
|
|
206
220
|
|
|
207
|
-
|
|
208
|
-
to a per-worker session file for external UIs (for example worker panes in
|
|
209
|
-
`claude-web-view`).
|
|
221
|
+
Workers rely on each CLI's native session persistence (no custom mirror writer):
|
|
210
222
|
|
|
211
|
-
-
|
|
212
|
-
-
|
|
213
|
-
-
|
|
214
|
-
- Codex workers use `codex-persist` writes; Claude workers use native `--session-id`
|
|
223
|
+
- Codex: native rollouts under `~/.codex/sessions/YYYY/MM/DD/*.jsonl`
|
|
224
|
+
- Claude: native project sessions under `~/.claude/projects/*/*.jsonl`
|
|
225
|
+
- Opencode: native session store managed by `opencode`
|
|
215
226
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
2. `codex-persist` on `PATH`
|
|
219
|
-
3. `node ~/git/codex-persist/dist/cli.js`
|
|
227
|
+
Oompa still tags the first prompt line with `[oompa:<swarmId>:<workerId>]`
|
|
228
|
+
so downstream UIs can identify and group worker conversations.
|
|
220
229
|
|
|
221
230
|
## Requirements
|
|
222
231
|
|
|
@@ -226,6 +235,7 @@ Resolution order for the CLI command:
|
|
|
226
235
|
- One of:
|
|
227
236
|
- [Claude CLI](https://github.com/anthropics/claude-cli)
|
|
228
237
|
- [Codex CLI](https://github.com/openai/codex)
|
|
238
|
+
- [Opencode CLI](https://github.com/sst/opencode)
|
|
229
239
|
|
|
230
240
|
## License
|
|
231
241
|
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
Supported Backends:
|
|
14
14
|
:codex - OpenAI Codex CLI (codex exec)
|
|
15
|
-
:claude - Anthropic Claude CLI (claude -p)
|
|
15
|
+
:claude - Anthropic Claude CLI (claude -p)
|
|
16
|
+
:opencode - opencode CLI (opencode run)"
|
|
16
17
|
(:require [agentnet.schema :as schema]
|
|
17
18
|
[babashka.process :as process]
|
|
18
19
|
[clojure.java.io :as io]
|
|
@@ -71,6 +72,22 @@
|
|
|
71
72
|
model (into ["--model" model])
|
|
72
73
|
true (conj "--dangerously-skip-permissions")))
|
|
73
74
|
|
|
75
|
+
(defn- opencode-attach-url
|
|
76
|
+
"Optional opencode server URL for run --attach mode."
|
|
77
|
+
[]
|
|
78
|
+
(let [url (or (System/getenv "OOMPA_OPENCODE_ATTACH")
|
|
79
|
+
(System/getenv "OPENCODE_ATTACH"))]
|
|
80
|
+
(when (and url (not (str/blank? url)))
|
|
81
|
+
url)))
|
|
82
|
+
|
|
83
|
+
(defmethod build-command :opencode
|
|
84
|
+
[_ {:keys [model]} prompt cwd]
|
|
85
|
+
(let [attach (opencode-attach-url)]
|
|
86
|
+
(cond-> ["opencode" "run"]
|
|
87
|
+
model (into ["-m" model])
|
|
88
|
+
attach (into ["--attach" attach])
|
|
89
|
+
true (conj prompt))))
|
|
90
|
+
|
|
74
91
|
(defmethod build-command :default
|
|
75
92
|
[agent-type _ _ _]
|
|
76
93
|
(throw (ex-info (str "Unknown agent type: " agent-type)
|
|
@@ -130,6 +147,11 @@
|
|
|
130
147
|
[output]
|
|
131
148
|
(boolean (re-find #"__DONE__" (or output ""))))
|
|
132
149
|
|
|
150
|
+
(defn merge-signal?
|
|
151
|
+
"Check if output contains COMPLETE_AND_READY_FOR_MERGE signal"
|
|
152
|
+
[output]
|
|
153
|
+
(boolean (re-find #"COMPLETE_AND_READY_FOR_MERGE" (or output ""))))
|
|
154
|
+
|
|
133
155
|
(defn- extract-comments
|
|
134
156
|
"Extract bullet-point comments from output"
|
|
135
157
|
[output]
|
|
@@ -264,6 +286,7 @@
|
|
|
264
286
|
(let [cmd (case agent-type
|
|
265
287
|
:codex ["codex" "--version"]
|
|
266
288
|
:claude ["claude" "--version"]
|
|
289
|
+
:opencode ["opencode" "--version"]
|
|
267
290
|
["echo" "unknown"])]
|
|
268
291
|
(try
|
|
269
292
|
(let [{:keys [exit]} (process/sh cmd {:out :string :err :string})]
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
./swarm.bb run # Run all tasks once
|
|
6
6
|
./swarm.bb run --workers 4 # With 4 parallel workers
|
|
7
7
|
./swarm.bb loop 20 --harness claude # 20 iterations with Claude
|
|
8
|
-
./swarm.bb loop --workers claude:5
|
|
8
|
+
./swarm.bb loop --workers claude:5 opencode:2 --iterations 20 # Mixed harnesses
|
|
9
9
|
./swarm.bb swarm oompa.json # Multi-model from config
|
|
10
10
|
./swarm.bb prompt \"...\" # Ad-hoc task
|
|
11
11
|
./swarm.bb status # Show last run
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
[agentnet.worker :as worker]
|
|
17
17
|
[agentnet.tasks :as tasks]
|
|
18
18
|
[agentnet.agent :as agent]
|
|
19
|
+
[agentnet.runs :as runs]
|
|
19
20
|
[babashka.process :as process]
|
|
20
21
|
[clojure.string :as str]
|
|
21
22
|
[clojure.java.io :as io]
|
|
@@ -30,20 +31,22 @@
|
|
|
30
31
|
(Integer/parseInt s)
|
|
31
32
|
(catch Exception _ default)))
|
|
32
33
|
|
|
34
|
+
(def ^:private harnesses #{:codex :claude :opencode})
|
|
35
|
+
|
|
33
36
|
(defn- make-swarm-id
|
|
34
37
|
"Generate a short run-level swarm ID."
|
|
35
38
|
[]
|
|
36
39
|
(subs (str (java.util.UUID/randomUUID)) 0 8))
|
|
37
40
|
|
|
38
41
|
(defn- parse-worker-spec
|
|
39
|
-
"Parse 'harness:count' into {:harness :
|
|
42
|
+
"Parse 'harness:count' into {:harness :opencode, :count 5}.
|
|
40
43
|
Throws on invalid format."
|
|
41
44
|
[s]
|
|
42
45
|
(let [[harness count-str] (str/split s #":" 2)
|
|
43
46
|
h (keyword harness)
|
|
44
47
|
cnt (parse-int count-str 0)]
|
|
45
|
-
(when-not (
|
|
46
|
-
(throw (ex-info (str "Unknown harness in worker spec: " s ". Use 'codex:N' or '
|
|
48
|
+
(when-not (harnesses h)
|
|
49
|
+
(throw (ex-info (str "Unknown harness in worker spec: " s ". Use 'codex:N', 'claude:N', or 'opencode:N'") {})))
|
|
47
50
|
(when (zero? cnt)
|
|
48
51
|
(throw (ex-info (str "Invalid count in worker spec: " s ". Use format 'harness:count'") {})))
|
|
49
52
|
{:harness h :count cnt}))
|
|
@@ -80,7 +83,7 @@
|
|
|
80
83
|
(= arg "--workers")
|
|
81
84
|
(let [next-arg (second remaining)]
|
|
82
85
|
(if (worker-spec? next-arg)
|
|
83
|
-
;; Collect all worker specs: --workers claude:5
|
|
86
|
+
;; Collect all worker specs: --workers claude:5 opencode:2
|
|
84
87
|
(let [[specs rest] (collect-worker-specs (next remaining))]
|
|
85
88
|
(recur (assoc opts :worker-specs specs) rest))
|
|
86
89
|
;; Simple count: --workers 4
|
|
@@ -93,8 +96,8 @@
|
|
|
93
96
|
|
|
94
97
|
(= arg "--harness")
|
|
95
98
|
(let [h (keyword (second remaining))]
|
|
96
|
-
(when-not (
|
|
97
|
-
(throw (ex-info (str "Unknown harness: " (second remaining) ". Use 'codex' or '
|
|
99
|
+
(when-not (harnesses h)
|
|
100
|
+
(throw (ex-info (str "Unknown harness: " (second remaining) ". Use 'codex', 'claude', or 'opencode'") {})))
|
|
98
101
|
(recur (assoc opts :harness h)
|
|
99
102
|
(nnext remaining)))
|
|
100
103
|
|
|
@@ -135,6 +138,21 @@
|
|
|
135
138
|
|
|
136
139
|
(declare cmd-swarm parse-model-string)
|
|
137
140
|
|
|
141
|
+
(defn- check-git-clean!
|
|
142
|
+
"Abort if git working tree is dirty. Dirty index causes merge conflicts
|
|
143
|
+
and wasted worker iterations."
|
|
144
|
+
[]
|
|
145
|
+
(let [result (process/sh ["git" "status" "--porcelain"]
|
|
146
|
+
{:out :string :err :string})
|
|
147
|
+
output (str/trim (:out result))]
|
|
148
|
+
(when (and (zero? (:exit result)) (not (str/blank? output)))
|
|
149
|
+
(println "ERROR: Git working tree is dirty. Resolve before running swarm.")
|
|
150
|
+
(println)
|
|
151
|
+
(println output)
|
|
152
|
+
(println)
|
|
153
|
+
(println "Run 'git stash' or 'git commit' first.")
|
|
154
|
+
(System/exit 1))))
|
|
155
|
+
|
|
138
156
|
(defn- probe-model
|
|
139
157
|
"Send 'say ok' to a model via its harness CLI. Returns true if model responds.
|
|
140
158
|
Claude hangs without /dev/null stdin when spawned from bb."
|
|
@@ -142,7 +160,8 @@
|
|
|
142
160
|
(try
|
|
143
161
|
(let [cmd (case harness
|
|
144
162
|
:claude ["claude" "--model" model "-p" "[oompa:probe] say ok" "--max-turns" "1"]
|
|
145
|
-
:codex ["codex" "exec" "--dangerously-bypass-approvals-and-sandbox" "--skip-git-repo-check" "--model" model "--" "[oompa:probe] say ok"]
|
|
163
|
+
:codex ["codex" "exec" "--dangerously-bypass-approvals-and-sandbox" "--skip-git-repo-check" "--model" model "--" "[oompa:probe] say ok"]
|
|
164
|
+
:opencode ["opencode" "run" "-m" model "[oompa:probe] say ok"])
|
|
146
165
|
null-in (io/input-stream (io/file "/dev/null"))
|
|
147
166
|
proc (process/process cmd {:out :string :err :string :in null-in})
|
|
148
167
|
result (deref proc 30000 :timeout)]
|
|
@@ -187,7 +206,7 @@
|
|
|
187
206
|
(cmd-swarm opts (or (seq args) ["oompa.json"]))
|
|
188
207
|
(let [swarm-id (make-swarm-id)]
|
|
189
208
|
(if-let [specs (:worker-specs opts)]
|
|
190
|
-
;; Mixed worker specs: --workers claude:5
|
|
209
|
+
;; Mixed worker specs: --workers claude:5 opencode:2
|
|
191
210
|
(let [workers (mapcat
|
|
192
211
|
(fn [spec]
|
|
193
212
|
(let [{:keys [harness count]} spec]
|
|
@@ -219,7 +238,7 @@
|
|
|
219
238
|
(:iterations opts)
|
|
220
239
|
20)]
|
|
221
240
|
(if-let [specs (:worker-specs opts)]
|
|
222
|
-
;; Mixed worker specs: --workers claude:5
|
|
241
|
+
;; Mixed worker specs: --workers claude:5 opencode:2
|
|
223
242
|
(let [workers (mapcat
|
|
224
243
|
(fn [spec]
|
|
225
244
|
(let [{:keys [harness count]} spec]
|
|
@@ -265,25 +284,65 @@
|
|
|
265
284
|
(orchestrator/run-once! opts))))
|
|
266
285
|
|
|
267
286
|
(defn cmd-status
|
|
268
|
-
"Show status of last run"
|
|
287
|
+
"Show status of last run — reads structured runs/{swarm-id}/ data."
|
|
269
288
|
[opts args]
|
|
270
|
-
(let [
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
(
|
|
289
|
+
(let [run-ids (runs/list-runs)]
|
|
290
|
+
(if (seq run-ids)
|
|
291
|
+
(let [swarm-id (or (first args) (first run-ids))
|
|
292
|
+
run-log (runs/read-run-log swarm-id)
|
|
293
|
+
summary (runs/read-summary swarm-id)
|
|
294
|
+
reviews (runs/list-reviews swarm-id)]
|
|
295
|
+
(println (format "Swarm: %s" swarm-id))
|
|
296
|
+
(when run-log
|
|
297
|
+
(println (format " Started: %s" (:started-at run-log)))
|
|
298
|
+
(println (format " Config: %s" (or (:config-file run-log) "N/A")))
|
|
299
|
+
(println (format " Workers: %d" (count (:workers run-log)))))
|
|
278
300
|
(println)
|
|
279
|
-
(
|
|
280
|
-
(
|
|
281
|
-
|
|
282
|
-
(
|
|
283
|
-
|
|
301
|
+
(if summary
|
|
302
|
+
(do
|
|
303
|
+
(println (format "Summary (finished %s):" (:finished-at summary)))
|
|
304
|
+
(println (format " Total completed: %d/%d iterations"
|
|
305
|
+
(:total-completed summary) (:total-iterations summary)))
|
|
306
|
+
(println (format " Status counts: %s" (pr-str (:status-counts summary))))
|
|
284
307
|
(println)
|
|
285
|
-
(println
|
|
286
|
-
|
|
308
|
+
(println "Per-worker:")
|
|
309
|
+
(doseq [w (:workers summary)]
|
|
310
|
+
(println (format " [%s] %s:%s — %s, %d completed, %d merges, %d rejections, %d errors, %d review rounds"
|
|
311
|
+
(:id w)
|
|
312
|
+
(or (:harness w) "unknown")
|
|
313
|
+
(or (:model w) "default")
|
|
314
|
+
(or (:status w) "unknown")
|
|
315
|
+
(or (:completed w) 0)
|
|
316
|
+
(or (:merges w) 0)
|
|
317
|
+
(or (:rejections w) 0)
|
|
318
|
+
(or (:errors w) 0)
|
|
319
|
+
(or (:review-rounds-total w) 0)))))
|
|
320
|
+
(println " (still running — no summary yet)"))
|
|
321
|
+
(when (seq reviews)
|
|
322
|
+
(println)
|
|
323
|
+
(println (format "Reviews: %d total" (count reviews)))
|
|
324
|
+
(doseq [r reviews]
|
|
325
|
+
(println (format " %s-i%d-r%d: %s"
|
|
326
|
+
(:worker-id r) (:iteration r) (:round r)
|
|
327
|
+
(:verdict r))))))
|
|
328
|
+
;; Fall back to legacy JSONL format
|
|
329
|
+
(let [runs-dir (io/file "runs")
|
|
330
|
+
files (when (.exists runs-dir)
|
|
331
|
+
(->> (.listFiles runs-dir)
|
|
332
|
+
(filter #(.isFile %))
|
|
333
|
+
(sort-by #(.lastModified %) >)))]
|
|
334
|
+
(if-let [latest (first files)]
|
|
335
|
+
(do
|
|
336
|
+
(println (format "Latest run (legacy): %s" (.getName latest)))
|
|
337
|
+
(println)
|
|
338
|
+
(with-open [r (io/reader latest)]
|
|
339
|
+
(let [entries (mapv #(json/parse-string % true) (line-seq r))
|
|
340
|
+
by-status (group-by :status entries)]
|
|
341
|
+
(doseq [[status tasks] (sort-by first by-status)]
|
|
342
|
+
(println (format "%s: %d" (name status) (count tasks))))
|
|
343
|
+
(println)
|
|
344
|
+
(println (format "Total: %d tasks" (count entries))))))
|
|
345
|
+
(println "No runs found."))))))
|
|
287
346
|
|
|
288
347
|
(defn cmd-worktrees
|
|
289
348
|
"List worktree status"
|
|
@@ -323,7 +382,7 @@
|
|
|
323
382
|
"Check if agent backends are available"
|
|
324
383
|
[opts args]
|
|
325
384
|
(println "Checking agent backends...")
|
|
326
|
-
(doseq [agent-type [:codex :claude]]
|
|
385
|
+
(doseq [agent-type [:codex :claude :opencode]]
|
|
327
386
|
(let [available? (agent/check-available agent-type)]
|
|
328
387
|
(println (format " %s: %s"
|
|
329
388
|
(name agent-type)
|
|
@@ -354,14 +413,48 @@
|
|
|
354
413
|
(println "{")
|
|
355
414
|
(println " \"workers\": [")
|
|
356
415
|
(println " {\"model\": \"codex:gpt-5.3-codex:medium\", \"prompt\": \"prompts/executor.md\", \"iterations\": 10, \"count\": 3, \"can_plan\": false},")
|
|
357
|
-
(println " {\"model\": \"claude:opus\", \"prompt\": [\"prompts/base.md\", \"prompts/planner.md\"], \"count\": 1}")
|
|
416
|
+
(println " {\"model\": \"claude:opus\", \"prompt\": [\"prompts/base.md\", \"prompts/planner.md\"], \"count\": 1},")
|
|
417
|
+
(println " {\"model\": \"opencode:openai/gpt-5\", \"prompt\": [\"prompts/executor.md\"], \"count\": 1}")
|
|
358
418
|
(println " ]")
|
|
359
419
|
(println "}")
|
|
360
420
|
(println)
|
|
361
421
|
(println "prompt: string or array of paths — concatenated into one prompt.")
|
|
362
422
|
(System/exit 1))
|
|
423
|
+
;; Preflight: abort if git is dirty to prevent merge conflicts
|
|
424
|
+
(check-git-clean!)
|
|
425
|
+
|
|
363
426
|
(let [config (json/parse-string (slurp f) true)
|
|
364
|
-
|
|
427
|
+
;; Parse reviewer config — supports both formats:
|
|
428
|
+
;; Legacy: {"review_model": "harness:model:reasoning"}
|
|
429
|
+
;; New: {"reviewer": {"model": "harness:model:reasoning", "prompt": ["path.md"]}}
|
|
430
|
+
reviewer-config (:reviewer config)
|
|
431
|
+
review-parsed (cond
|
|
432
|
+
reviewer-config
|
|
433
|
+
(let [parsed (parse-model-string (:model reviewer-config))
|
|
434
|
+
prompts (let [p (:prompt reviewer-config)]
|
|
435
|
+
(cond (vector? p) p
|
|
436
|
+
(string? p) [p]
|
|
437
|
+
:else []))]
|
|
438
|
+
(assoc parsed :prompts prompts))
|
|
439
|
+
|
|
440
|
+
(:review_model config)
|
|
441
|
+
(parse-model-string (:review_model config))
|
|
442
|
+
|
|
443
|
+
:else nil)
|
|
444
|
+
|
|
445
|
+
;; Parse planner config — optional dedicated planner
|
|
446
|
+
;; Runs in project root, no worktree/review/merge, respects max_pending backpressure
|
|
447
|
+
planner-config (:planner config)
|
|
448
|
+
planner-parsed (when planner-config
|
|
449
|
+
(let [parsed (parse-model-string (:model planner-config))
|
|
450
|
+
prompts (let [p (:prompt planner-config)]
|
|
451
|
+
(cond (vector? p) p
|
|
452
|
+
(string? p) [p]
|
|
453
|
+
:else []))]
|
|
454
|
+
(assoc parsed
|
|
455
|
+
:prompts prompts
|
|
456
|
+
:max-pending (or (:max_pending planner-config) 10))))
|
|
457
|
+
|
|
365
458
|
worker-configs (:workers config)
|
|
366
459
|
|
|
367
460
|
;; Expand worker configs by count
|
|
@@ -383,14 +476,28 @@
|
|
|
383
476
|
:iterations (or (:iterations wc) 10)
|
|
384
477
|
:prompts (:prompt wc)
|
|
385
478
|
:can-plan (:can_plan wc)
|
|
386
|
-
:review-harness (:harness review-
|
|
387
|
-
:review-model (:model review-
|
|
479
|
+
:review-harness (:harness review-parsed)
|
|
480
|
+
:review-model (:model review-parsed)
|
|
481
|
+
:review-prompts (:prompts review-parsed)})))
|
|
388
482
|
expanded-workers)]
|
|
389
483
|
|
|
390
484
|
(println (format "Swarm config from %s:" config-file))
|
|
391
485
|
(println (format " Swarm ID: %s" swarm-id))
|
|
392
|
-
(when
|
|
393
|
-
(println (format "
|
|
486
|
+
(when planner-parsed
|
|
487
|
+
(println (format " Planner: %s:%s (max_pending: %d%s)"
|
|
488
|
+
(name (:harness planner-parsed))
|
|
489
|
+
(:model planner-parsed)
|
|
490
|
+
(:max-pending planner-parsed)
|
|
491
|
+
(if (seq (:prompts planner-parsed))
|
|
492
|
+
(str ", prompts: " (str/join ", " (:prompts planner-parsed)))
|
|
493
|
+
""))))
|
|
494
|
+
(when review-parsed
|
|
495
|
+
(println (format " Reviewer: %s:%s%s"
|
|
496
|
+
(name (:harness review-parsed))
|
|
497
|
+
(:model review-parsed)
|
|
498
|
+
(if (seq (:prompts review-parsed))
|
|
499
|
+
(str " (prompts: " (str/join ", " (:prompts review-parsed)) ")")
|
|
500
|
+
""))))
|
|
394
501
|
(println (format " Workers: %d total" (count workers)))
|
|
395
502
|
(doseq [[idx wc] (map-indexed vector worker-configs)]
|
|
396
503
|
(let [{:keys [harness model reasoning]} (parse-model-string (:model wc))]
|
|
@@ -404,7 +511,27 @@
|
|
|
404
511
|
(println)
|
|
405
512
|
|
|
406
513
|
;; Preflight: probe each unique model before launching workers
|
|
407
|
-
|
|
514
|
+
;; Include planner model in validation if configured
|
|
515
|
+
(validate-models! (cond-> worker-configs
|
|
516
|
+
planner-config (conj planner-config))
|
|
517
|
+
review-parsed)
|
|
518
|
+
|
|
519
|
+
;; Write run log to runs/{swarm-id}/run.edn
|
|
520
|
+
(runs/write-run-log! swarm-id
|
|
521
|
+
{:workers workers
|
|
522
|
+
:planner-config planner-parsed
|
|
523
|
+
:reviewer-config review-parsed
|
|
524
|
+
:config-file config-file})
|
|
525
|
+
(println (format "\nRun log written to runs/%s/run.edn" swarm-id))
|
|
526
|
+
|
|
527
|
+
;; Run planner if configured — synchronously before workers
|
|
528
|
+
(when planner-parsed
|
|
529
|
+
(println)
|
|
530
|
+
(println (format " Planner: %s:%s (max_pending: %d)"
|
|
531
|
+
(name (:harness planner-parsed))
|
|
532
|
+
(:model planner-parsed)
|
|
533
|
+
(:max-pending planner-parsed)))
|
|
534
|
+
(worker/run-planner! (assoc planner-parsed :swarm-id swarm-id)))
|
|
408
535
|
|
|
409
536
|
;; Run workers using new worker module
|
|
410
537
|
(worker/run-workers! workers))))
|
|
@@ -450,16 +577,16 @@
|
|
|
450
577
|
(println)
|
|
451
578
|
(println "Options:")
|
|
452
579
|
(println " --workers N Number of parallel workers (default: 2)")
|
|
453
|
-
(println " --workers H:N [H:N ...] Mixed workers by harness (e.g., claude:5
|
|
580
|
+
(println " --workers H:N [H:N ...] Mixed workers by harness (e.g., claude:5 opencode:2)")
|
|
454
581
|
(println " --iterations N Number of iterations per worker (default: 1)")
|
|
455
|
-
(println " --harness {codex,claude} Agent harness to use (default: codex)")
|
|
456
|
-
(println " --model MODEL Model to use (e.g., codex-5.2, opus-4.5)")
|
|
582
|
+
(println " --harness {codex,claude,opencode} Agent harness to use (default: codex)")
|
|
583
|
+
(println " --model MODEL Model to use (e.g., codex-5.2, opus-4.5, openai/gpt-5)")
|
|
457
584
|
(println " --dry-run Skip actual merges")
|
|
458
585
|
(println " --keep-worktrees Don't cleanup worktrees after run")
|
|
459
586
|
(println)
|
|
460
587
|
(println "Examples:")
|
|
461
588
|
(println " ./swarm.bb loop 10 --harness codex --model gpt-5.3-codex --workers 3")
|
|
462
|
-
(println " ./swarm.bb loop --workers claude:5
|
|
589
|
+
(println " ./swarm.bb loop --workers claude:5 opencode:2 --iterations 20")
|
|
463
590
|
(println " ./swarm.bb swarm oompa.json # Run multi-model config"))
|
|
464
591
|
|
|
465
592
|
;; =============================================================================
|
|
@@ -325,7 +325,7 @@
|
|
|
325
325
|
"Convenience: create orchestrator, run, save log, shutdown.
|
|
326
326
|
|
|
327
327
|
Arguments:
|
|
328
|
-
opts - {:workers N, :harness :codex|:claude, :model string,
|
|
328
|
+
opts - {:workers N, :harness :codex|:claude|:opencode, :model string,
|
|
329
329
|
:review-harness keyword, :review-model string,
|
|
330
330
|
:custom-prompt string, :dry-run bool}
|
|
331
331
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
(ns agentnet.runs
|
|
2
|
+
"Structured persistence for swarm runs.
|
|
3
|
+
|
|
4
|
+
Layout:
|
|
5
|
+
runs/{swarm-id}/
|
|
6
|
+
run.json — start time, worker configs, planner output
|
|
7
|
+
summary.json — final metrics per worker, aggregate stats
|
|
8
|
+
reviews/
|
|
9
|
+
{worker-id}-i{N}-r{round}.json — per-iteration review log
|
|
10
|
+
|
|
11
|
+
Written as JSON for easy consumption by claude-web-view and CLI.
|
|
12
|
+
All writes are atomic (write to .tmp, rename) to avoid partial reads."
|
|
13
|
+
(:require [clojure.java.io :as io]
|
|
14
|
+
[clojure.string :as str]
|
|
15
|
+
[cheshire.core :as json]))
|
|
16
|
+
|
|
17
|
+
;; =============================================================================
|
|
18
|
+
;; Paths
|
|
19
|
+
;; =============================================================================
|
|
20
|
+
|
|
21
|
+
(def ^:const RUNS_ROOT "runs")
|
|
22
|
+
|
|
23
|
+
(defn- run-dir [swarm-id]
|
|
24
|
+
(str RUNS_ROOT "/" swarm-id))
|
|
25
|
+
|
|
26
|
+
(defn- reviews-dir [swarm-id]
|
|
27
|
+
(str (run-dir swarm-id) "/reviews"))
|
|
28
|
+
|
|
29
|
+
(defn- ensure-run-dirs! [swarm-id]
|
|
30
|
+
(.mkdirs (io/file (reviews-dir swarm-id))))
|
|
31
|
+
|
|
32
|
+
;; =============================================================================
|
|
33
|
+
;; Atomic Write
|
|
34
|
+
;; =============================================================================
|
|
35
|
+
|
|
36
|
+
(defn- write-json!
|
|
37
|
+
"Write JSON data atomically: write to .tmp, rename to final path."
|
|
38
|
+
[path data]
|
|
39
|
+
(let [f (io/file path)
|
|
40
|
+
tmp (io/file (str path ".tmp"))]
|
|
41
|
+
(.mkdirs (.getParentFile f))
|
|
42
|
+
(spit tmp (json/generate-string data {:pretty true}))
|
|
43
|
+
(.renameTo tmp f)))
|
|
44
|
+
|
|
45
|
+
;; =============================================================================
|
|
46
|
+
;; Run Log — written at swarm start
|
|
47
|
+
;; =============================================================================
|
|
48
|
+
|
|
49
|
+
(defn write-run-log!
|
|
50
|
+
"Write the initial run log when a swarm starts.
|
|
51
|
+
Contains: start time, worker configs, planner output, swarm metadata."
|
|
52
|
+
[swarm-id {:keys [workers planner-config reviewer-config config-file]}]
|
|
53
|
+
(ensure-run-dirs! swarm-id)
|
|
54
|
+
(write-json! (str (run-dir swarm-id) "/run.json")
|
|
55
|
+
{:swarm-id swarm-id
|
|
56
|
+
:started-at (str (java.time.Instant/now))
|
|
57
|
+
:config-file config-file
|
|
58
|
+
:workers (mapv (fn [w]
|
|
59
|
+
{:id (:id w)
|
|
60
|
+
:harness (name (:harness w))
|
|
61
|
+
:model (:model w)
|
|
62
|
+
:reasoning (:reasoning w)
|
|
63
|
+
:iterations (:iterations w)
|
|
64
|
+
:can-plan (:can-plan w)
|
|
65
|
+
:prompts (:prompts w)})
|
|
66
|
+
workers)
|
|
67
|
+
:planner (when planner-config
|
|
68
|
+
{:harness (name (:harness planner-config))
|
|
69
|
+
:model (:model planner-config)
|
|
70
|
+
:prompts (:prompts planner-config)
|
|
71
|
+
:max-pending (:max-pending planner-config)})
|
|
72
|
+
:reviewer (when reviewer-config
|
|
73
|
+
{:harness (name (:harness reviewer-config))
|
|
74
|
+
:model (:model reviewer-config)
|
|
75
|
+
:prompts (:prompts reviewer-config)})}))
|
|
76
|
+
|
|
77
|
+
;; =============================================================================
|
|
78
|
+
;; Review Log — written after each review round
|
|
79
|
+
;; =============================================================================
|
|
80
|
+
|
|
81
|
+
(defn write-review-log!
|
|
82
|
+
"Write a review log for one iteration of a worker.
|
|
83
|
+
Contains: verdict, round number, full reviewer output, diff file list."
|
|
84
|
+
[swarm-id worker-id iteration round
|
|
85
|
+
{:keys [verdict output diff-files]}]
|
|
86
|
+
(ensure-run-dirs! swarm-id)
|
|
87
|
+
(let [filename (format "%s-i%d-r%d.json" worker-id iteration round)]
|
|
88
|
+
(write-json! (str (reviews-dir swarm-id) "/" filename)
|
|
89
|
+
{:worker-id worker-id
|
|
90
|
+
:iteration iteration
|
|
91
|
+
:round round
|
|
92
|
+
:verdict (name verdict)
|
|
93
|
+
:timestamp (str (java.time.Instant/now))
|
|
94
|
+
:output output
|
|
95
|
+
:diff-files (vec diff-files)})))
|
|
96
|
+
|
|
97
|
+
;; =============================================================================
|
|
98
|
+
;; Swarm Summary — written at swarm end
|
|
99
|
+
;; =============================================================================
|
|
100
|
+
|
|
101
|
+
(defn write-summary!
|
|
102
|
+
"Write the final swarm summary after all workers complete.
|
|
103
|
+
Contains: per-worker stats and aggregate metrics."
|
|
104
|
+
[swarm-id worker-results]
|
|
105
|
+
(let [total-completed (reduce + 0 (map :completed worker-results))
|
|
106
|
+
total-iterations (reduce + 0 (map :iterations worker-results))
|
|
107
|
+
statuses (frequencies (map #(name (:status %)) worker-results))
|
|
108
|
+
per-worker (mapv (fn [w]
|
|
109
|
+
{:id (:id w)
|
|
110
|
+
:harness (name (:harness w))
|
|
111
|
+
:model (:model w)
|
|
112
|
+
:status (name (:status w))
|
|
113
|
+
:completed (:completed w)
|
|
114
|
+
:iterations (:iterations w)
|
|
115
|
+
:merges (or (:merges w) 0)
|
|
116
|
+
:rejections (or (:rejections w) 0)
|
|
117
|
+
:errors (or (:errors w) 0)
|
|
118
|
+
:review-rounds-total (or (:review-rounds-total w) 0)})
|
|
119
|
+
worker-results)]
|
|
120
|
+
(write-json! (str (run-dir swarm-id) "/summary.json")
|
|
121
|
+
{:swarm-id swarm-id
|
|
122
|
+
:finished-at (str (java.time.Instant/now))
|
|
123
|
+
:total-workers (count worker-results)
|
|
124
|
+
:total-completed total-completed
|
|
125
|
+
:total-iterations total-iterations
|
|
126
|
+
:status-counts statuses
|
|
127
|
+
:workers per-worker})))
|
|
128
|
+
|
|
129
|
+
;; =============================================================================
|
|
130
|
+
;; Read helpers (for cmd-status, dashboards)
|
|
131
|
+
;; =============================================================================
|
|
132
|
+
|
|
133
|
+
(defn list-runs
|
|
134
|
+
"List all swarm run directories, newest first."
|
|
135
|
+
[]
|
|
136
|
+
(let [d (io/file RUNS_ROOT)]
|
|
137
|
+
(when (.exists d)
|
|
138
|
+
(->> (.listFiles d)
|
|
139
|
+
(filter #(.isDirectory %))
|
|
140
|
+
(sort-by #(.lastModified %) >)
|
|
141
|
+
(mapv #(.getName %))))))
|
|
142
|
+
|
|
143
|
+
(defn read-run-log
|
|
144
|
+
"Read run.json for a swarm."
|
|
145
|
+
[swarm-id]
|
|
146
|
+
(let [f (io/file (str (run-dir swarm-id) "/run.json"))]
|
|
147
|
+
(when (.exists f)
|
|
148
|
+
(json/parse-string (slurp f) true))))
|
|
149
|
+
|
|
150
|
+
(defn read-summary
|
|
151
|
+
"Read summary.json for a swarm."
|
|
152
|
+
[swarm-id]
|
|
153
|
+
(let [f (io/file (str (run-dir swarm-id) "/summary.json"))]
|
|
154
|
+
(when (.exists f)
|
|
155
|
+
(json/parse-string (slurp f) true))))
|
|
156
|
+
|
|
157
|
+
(defn list-reviews
|
|
158
|
+
"List all review logs for a swarm, sorted by filename."
|
|
159
|
+
[swarm-id]
|
|
160
|
+
(let [d (io/file (reviews-dir swarm-id))]
|
|
161
|
+
(when (.exists d)
|
|
162
|
+
(->> (.listFiles d)
|
|
163
|
+
(filter #(str/ends-with? (.getName %) ".json"))
|
|
164
|
+
(sort-by #(.getName %))
|
|
165
|
+
(mapv (fn [f] (json/parse-string (slurp f) true)))))))
|