@nbardy/oompa 0.7.2 → 0.7.3
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 +6 -4
- package/agentnet/src/agentnet/agent.clj +9 -8
- package/agentnet/src/agentnet/cli.clj +268 -77
- package/agentnet/src/agentnet/cli.clj.bak +1384 -0
- package/agentnet/src/agentnet/core.clj +17 -2
- package/agentnet/src/agentnet/harness.clj +69 -36
- package/agentnet/src/agentnet/runs.clj +5 -3
- package/agentnet/src/agentnet/tasks.clj +6 -0
- package/agentnet/src/agentnet/worker.clj +676 -226
- package/bin/oompa.js +5 -1
- package/config/prompts/_task_header.md +2 -2
- package/oompa.example.json +4 -4
- package/package.json +5 -3
- package/scripts/README.md +6 -0
- package/scripts/__pycache__/stream_bridge.cpython-314.pyc +0 -0
- package/scripts/copy-repo-code.sh +110 -0
- package/scripts/install-babashka.js +97 -0
- package/scripts/test-harness-resume.sh +229 -0
|
@@ -0,0 +1,1384 @@
|
|
|
1
|
+
(ns agentnet.cli
|
|
2
|
+
"Command-line interface for AgentNet orchestrator.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
./swarm.bb run # Run swarm from config (oompa.json)
|
|
6
|
+
./swarm.bb run --detach --config oompa.json # Run in background with startup validation
|
|
7
|
+
./swarm.bb loop 20 --harness claude # 20 iterations with Claude
|
|
8
|
+
./swarm.bb loop --workers claude:5 opencode:2 --iterations 20 # Mixed harnesses
|
|
9
|
+
./swarm.bb swarm oompa.json # Multi-model from config
|
|
10
|
+
./swarm.bb prompt \"...\" # Ad-hoc task
|
|
11
|
+
./swarm.bb status # Show last run
|
|
12
|
+
./swarm.bb worktrees # List worktree status
|
|
13
|
+
./swarm.bb cleanup # Remove all worktrees"
|
|
14
|
+
(:require [agentnet.orchestrator :as orchestrator]
|
|
15
|
+
[agentnet.worktree :as worktree]
|
|
16
|
+
[agentnet.worker :as worker]
|
|
17
|
+
[agentnet.tasks :as tasks]
|
|
18
|
+
[agentnet.agent :as agent]
|
|
19
|
+
[agentnet.harness :as harness]
|
|
20
|
+
[agentnet.runs :as runs]
|
|
21
|
+
[babashka.process :as process]
|
|
22
|
+
[clojure.string :as str]
|
|
23
|
+
[clojure.java.io :as io]
|
|
24
|
+
[cheshire.core :as json]))
|
|
25
|
+
|
|
26
|
+
;; =============================================================================
|
|
27
|
+
;; Argument Parsing
|
|
28
|
+
;; =============================================================================
|
|
29
|
+
|
|
30
|
+
(defn- parse-int [s default]
|
|
31
|
+
(try
|
|
32
|
+
(Integer/parseInt s)
|
|
33
|
+
(catch Exception _ default)))
|
|
34
|
+
|
|
35
|
+
(def ^:private harnesses (harness/known-harnesses))
|
|
36
|
+
|
|
37
|
+
(defn- make-swarm-id
|
|
38
|
+
"Generate a short run-level swarm ID."
|
|
39
|
+
[]
|
|
40
|
+
(subs (str (java.util.UUID/randomUUID)) 0 8))
|
|
41
|
+
|
|
42
|
+
(defn- parse-worker-spec
|
|
43
|
+
"Parse 'harness:count' into {:harness :opencode, :count 5}.
|
|
44
|
+
Throws on invalid format."
|
|
45
|
+
[s]
|
|
46
|
+
(let [[harness count-str] (str/split s #":" 2)
|
|
47
|
+
h (keyword harness)
|
|
48
|
+
cnt (parse-int count-str 0)]
|
|
49
|
+
(when-not (harness/valid-harness? h)
|
|
50
|
+
(throw (ex-info (str "Unknown harness in worker spec: " s ". Known: " (str/join ", " (map name (sort harnesses)))) {})))
|
|
51
|
+
(when (zero? cnt)
|
|
52
|
+
(throw (ex-info (str "Invalid count in worker spec: " s ". Use format 'harness:count'") {})))
|
|
53
|
+
{:harness h :count cnt}))
|
|
54
|
+
|
|
55
|
+
(defn- worker-spec? [s]
|
|
56
|
+
"Check if string looks like 'harness:count' format"
|
|
57
|
+
(and (string? s)
|
|
58
|
+
(not (str/starts-with? s "--"))
|
|
59
|
+
(str/includes? s ":")
|
|
60
|
+
(re-matches #"[a-z]+:\d+" s)))
|
|
61
|
+
|
|
62
|
+
(defn- collect-worker-specs
|
|
63
|
+
"Collect consecutive worker specs from args. Returns [specs remaining-args]."
|
|
64
|
+
[args]
|
|
65
|
+
(loop [specs []
|
|
66
|
+
remaining args]
|
|
67
|
+
(if-let [arg (first remaining)]
|
|
68
|
+
(if (worker-spec? arg)
|
|
69
|
+
(recur (conj specs (parse-worker-spec arg)) (next remaining))
|
|
70
|
+
[specs remaining])
|
|
71
|
+
[specs remaining])))
|
|
72
|
+
|
|
73
|
+
(defn parse-args [args]
|
|
74
|
+
(loop [opts {:workers 2
|
|
75
|
+
:harness :codex
|
|
76
|
+
:model nil
|
|
77
|
+
:dry-run false
|
|
78
|
+
:detach false
|
|
79
|
+
:all false
|
|
80
|
+
:config-file nil
|
|
81
|
+
:startup-timeout nil
|
|
82
|
+
:iterations 1
|
|
83
|
+
:worker-specs nil}
|
|
84
|
+
remaining args]
|
|
85
|
+
(if-let [arg (first remaining)]
|
|
86
|
+
(cond
|
|
87
|
+
;; --workers can take either N or harness:count specs
|
|
88
|
+
(= arg "--workers")
|
|
89
|
+
(let [next-arg (second remaining)]
|
|
90
|
+
(if (worker-spec? next-arg)
|
|
91
|
+
;; Collect all worker specs: --workers claude:5 opencode:2
|
|
92
|
+
(let [[specs rest] (collect-worker-specs (next remaining))]
|
|
93
|
+
(recur (assoc opts :worker-specs specs) rest))
|
|
94
|
+
;; Simple count: --workers 4
|
|
95
|
+
(recur (assoc opts :workers (parse-int next-arg 2))
|
|
96
|
+
(nnext remaining))))
|
|
97
|
+
|
|
98
|
+
(= arg "--iterations")
|
|
99
|
+
(recur (assoc opts :iterations (parse-int (second remaining) 1))
|
|
100
|
+
(nnext remaining))
|
|
101
|
+
|
|
102
|
+
(= arg "--harness")
|
|
103
|
+
(let [h (keyword (second remaining))]
|
|
104
|
+
(when-not (harness/valid-harness? h)
|
|
105
|
+
(throw (ex-info (str "Unknown harness: " (second remaining) ". Known: " (str/join ", " (map name (sort harnesses)))) {})))
|
|
106
|
+
(recur (assoc opts :harness h)
|
|
107
|
+
(nnext remaining)))
|
|
108
|
+
|
|
109
|
+
(= arg "--model")
|
|
110
|
+
(recur (assoc opts :model (second remaining))
|
|
111
|
+
(nnext remaining))
|
|
112
|
+
|
|
113
|
+
(= arg "--config")
|
|
114
|
+
(let [config-file (second remaining)]
|
|
115
|
+
(when (str/blank? config-file)
|
|
116
|
+
(throw (ex-info "--config requires a path" {:arg arg})))
|
|
117
|
+
(recur (assoc opts :config-file config-file)
|
|
118
|
+
(nnext remaining)))
|
|
119
|
+
|
|
120
|
+
(or (= arg "--detach") (= arg "--dettach"))
|
|
121
|
+
(recur (assoc opts :detach true)
|
|
122
|
+
(next remaining))
|
|
123
|
+
|
|
124
|
+
(= arg "--all")
|
|
125
|
+
(recur (assoc opts :all true)
|
|
126
|
+
(next remaining))
|
|
127
|
+
|
|
128
|
+
(= arg "--startup-timeout")
|
|
129
|
+
(let [seconds (parse-int (second remaining) nil)]
|
|
130
|
+
(when-not (and (number? seconds) (pos? seconds))
|
|
131
|
+
(throw (ex-info "--startup-timeout requires a positive integer (seconds)" {:arg arg})))
|
|
132
|
+
(recur (assoc opts :startup-timeout seconds)
|
|
133
|
+
(nnext remaining)))
|
|
134
|
+
|
|
135
|
+
;; Legacy flags (still supported)
|
|
136
|
+
(= arg "--claude")
|
|
137
|
+
(recur (assoc opts :harness :claude)
|
|
138
|
+
(next remaining))
|
|
139
|
+
|
|
140
|
+
(= arg "--codex")
|
|
141
|
+
(recur (assoc opts :harness :codex)
|
|
142
|
+
(next remaining))
|
|
143
|
+
|
|
144
|
+
(= arg "--dry-run")
|
|
145
|
+
(recur (assoc opts :dry-run true)
|
|
146
|
+
(next remaining))
|
|
147
|
+
|
|
148
|
+
(= arg "--keep-worktrees")
|
|
149
|
+
(recur (assoc opts :keep-worktrees true)
|
|
150
|
+
(next remaining))
|
|
151
|
+
|
|
152
|
+
(= arg "--")
|
|
153
|
+
{:opts opts :args (vec (next remaining))}
|
|
154
|
+
|
|
155
|
+
(str/starts-with? arg "--")
|
|
156
|
+
(throw (ex-info (str "Unknown option: " arg) {:arg arg}))
|
|
157
|
+
|
|
158
|
+
:else
|
|
159
|
+
{:opts opts :args (vec remaining)})
|
|
160
|
+
{:opts opts :args []})))
|
|
161
|
+
|
|
162
|
+
;; =============================================================================
|
|
163
|
+
;; Commands
|
|
164
|
+
;; =============================================================================
|
|
165
|
+
|
|
166
|
+
(declare cmd-swarm parse-model-string pid-alive?)
|
|
167
|
+
|
|
168
|
+
(defn- check-git-clean!
|
|
169
|
+
"Warn if git working tree is dirty. Dirty index may cause merge conflicts."
|
|
170
|
+
[]
|
|
171
|
+
(let [result (process/sh ["git" "status" "--porcelain"]
|
|
172
|
+
{:out :string :err :string})
|
|
173
|
+
output (str/trim (:out result))]
|
|
174
|
+
(when (and (zero? (:exit result)) (not (str/blank? output)))
|
|
175
|
+
(println "WARNING: Git working tree is dirty. You may experience merge conflicts.")
|
|
176
|
+
(println output))))
|
|
177
|
+
|
|
178
|
+
(defn- dirty-worktree?
|
|
179
|
+
"Returns true if the git worktree at path has uncommitted changes."
|
|
180
|
+
[path]
|
|
181
|
+
(let [{:keys [exit out]} (process/sh ["git" "-C" path "status" "--porcelain"]
|
|
182
|
+
{:out :string :err :string})]
|
|
183
|
+
(and (zero? exit) (not (str/blank? out)))))
|
|
184
|
+
|
|
185
|
+
(defn- worktree-branch-name
|
|
186
|
+
"Returns the current branch name for the worktree at path, or nil on failure."
|
|
187
|
+
[path]
|
|
188
|
+
(let [{:keys [exit out]} (process/sh ["git" "-C" path "rev-parse" "--abbrev-ref" "HEAD"]
|
|
189
|
+
{:out :string :err :string})]
|
|
190
|
+
(when (zero? exit) (str/trim out))))
|
|
191
|
+
|
|
192
|
+
(defn- remove-stale-worktree!
|
|
193
|
+
"Remove a stale worktree directory and delete its branch."
|
|
194
|
+
[path branch]
|
|
195
|
+
(process/sh ["git" "worktree" "remove" "--force" path] {:out :string :err :string})
|
|
196
|
+
(when (and branch (not (str/blank? branch)) (not= branch "HEAD"))
|
|
197
|
+
(process/sh ["git" "branch" "-D" branch] {:out :string :err :string})))
|
|
198
|
+
|
|
199
|
+
(defn- run-stale-review!
|
|
200
|
+
"Invoke the reviewer model on partial worktree changes.
|
|
201
|
+
Tries each reviewer in the fallback chain until one returns a verdict.
|
|
202
|
+
Returns :merge to merge the branch into main, :discard to throw it away."
|
|
203
|
+
[reviewer-configs worktree-path branch]
|
|
204
|
+
(let [diff-out (:out (process/sh ["git" "-C" worktree-path "diff" "HEAD"]
|
|
205
|
+
{:out :string :err :string}))
|
|
206
|
+
diff (if (> (count diff-out) 8000)
|
|
207
|
+
(str (subs diff-out 0 8000) "\n...[diff truncated at 8000 chars]")
|
|
208
|
+
diff-out)
|
|
209
|
+
status-out (:out (process/sh ["git" "-C" worktree-path "status" "--short"]
|
|
210
|
+
{:out :string}))
|
|
211
|
+
prompt (str "You are reviewing partial/incomplete changes from an interrupted swarm run.\n\n"
|
|
212
|
+
"Branch: " branch "\n"
|
|
213
|
+
"Status:\n" status-out "\n\n"
|
|
214
|
+
"Diff:\n```\n" diff "\n```\n\n"
|
|
215
|
+
"Should these changes be merged into main or discarded?\n"
|
|
216
|
+
"MERGE if: changes are correct, complete, or valuable enough to keep.\n"
|
|
217
|
+
"DISCARD if: changes are broken, trivial, or not worth merging.\n\n"
|
|
218
|
+
"Your verdict MUST appear on its own line, exactly one of:\n"
|
|
219
|
+
"VERDICT: MERGE\n"
|
|
220
|
+
"VERDICT: DISCARD\n\n"
|
|
221
|
+
"Then briefly explain why.\n")
|
|
222
|
+
result (reduce (fn [_ {:keys [harness model]}]
|
|
223
|
+
(try
|
|
224
|
+
(let [cmd (harness/build-cmd harness {:model model :prompt prompt})
|
|
225
|
+
res (process/sh cmd
|
|
226
|
+
{:in (harness/process-stdin harness prompt)
|
|
227
|
+
:out :string :err :string})
|
|
228
|
+
output (:out res)
|
|
229
|
+
has-verdict? (or (re-find #"VERDICT:\s*MERGE" output)
|
|
230
|
+
(re-find #"VERDICT:\s*DISCARD" output))]
|
|
231
|
+
(if (and (zero? (:exit res)) has-verdict?)
|
|
232
|
+
(reduced res)
|
|
233
|
+
res))
|
|
234
|
+
(catch Exception e
|
|
235
|
+
{:exit -1 :out "" :err (.getMessage e)})))
|
|
236
|
+
{:exit -1 :out ""}
|
|
237
|
+
reviewer-configs)
|
|
238
|
+
output (:out result)]
|
|
239
|
+
(cond
|
|
240
|
+
(re-find #"VERDICT:\s*MERGE" output) :merge
|
|
241
|
+
(re-find #"VERDICT:\s*DISCARD" output) :discard
|
|
242
|
+
:else :discard)))
|
|
243
|
+
|
|
244
|
+
(defn- handle-stale-worktrees!
|
|
245
|
+
"Non-destructive startup check for existing oompa worktrees.
|
|
246
|
+
|
|
247
|
+
- Always runs `git worktree prune` to clear orphaned metadata.
|
|
248
|
+
- Never auto-removes/merges/discards worktrees at startup.
|
|
249
|
+
- Prints a warning summary so concurrent swarms are not disrupted.
|
|
250
|
+
|
|
251
|
+
This avoids clobbering active work from another swarm in the same repo."
|
|
252
|
+
[_reviewer-configs]
|
|
253
|
+
;; Step 1: prune orphaned git metadata first
|
|
254
|
+
(let [prune-result (process/sh ["git" "worktree" "prune"] {:out :string :err :string})]
|
|
255
|
+
(when-not (zero? (:exit prune-result))
|
|
256
|
+
(println "WARNING: git worktree prune failed:")
|
|
257
|
+
(println (:err prune-result))))
|
|
258
|
+
|
|
259
|
+
;; Step 2: discover existing oompa worktree dirs and oompa/* branches
|
|
260
|
+
(let [ls-result (process/sh ["find" "." "-maxdepth" "1" "-type" "d" "-name" ".w*-i*"]
|
|
261
|
+
{:out :string})
|
|
262
|
+
stale-dirs (when (zero? (:exit ls-result))
|
|
263
|
+
(->> (str/split-lines (:out ls-result))
|
|
264
|
+
(remove str/blank?)))
|
|
265
|
+
br-result (process/sh ["git" "branch" "--list" "oompa/*"] {:out :string})
|
|
266
|
+
all-oompa-branches (when (zero? (:exit br-result))
|
|
267
|
+
(->> (str/split-lines (:out br-result))
|
|
268
|
+
(map str/trim)
|
|
269
|
+
(remove str/blank?)))]
|
|
270
|
+
|
|
271
|
+
(when (or (seq stale-dirs) (seq all-oompa-branches))
|
|
272
|
+
;; Step 3: classify for warning output only (no mutation)
|
|
273
|
+
(let [classified (mapv (fn [dir]
|
|
274
|
+
{:dir dir
|
|
275
|
+
:branch (worktree-branch-name dir)
|
|
276
|
+
:dirty? (dirty-worktree? dir)})
|
|
277
|
+
stale-dirs)
|
|
278
|
+
clean (filter (complement :dirty?) classified)
|
|
279
|
+
dirty (filter :dirty? classified)
|
|
280
|
+
dir-branches (set (keep :branch classified))
|
|
281
|
+
orphan-branches (remove #(contains? dir-branches %) all-oompa-branches)]
|
|
282
|
+
(println)
|
|
283
|
+
(println "WARNING: Existing oompa worktrees/branches detected; leaving them untouched.")
|
|
284
|
+
(println (format " Worktrees: %d (%d dirty, %d clean)"
|
|
285
|
+
(count classified) (count dirty) (count clean)))
|
|
286
|
+
(println (format " Orphan branches: %d" (count orphan-branches)))
|
|
287
|
+
(when (seq dirty)
|
|
288
|
+
(println " Dirty worktrees:")
|
|
289
|
+
(doseq [{:keys [dir branch]} dirty]
|
|
290
|
+
(println (format " %s (branch: %s)" dir (or branch "unknown")))))
|
|
291
|
+
(println " Run `oompa cleanup` manually when you want to reclaim them.")))))
|
|
292
|
+
|
|
293
|
+
(defn- cleanup-iteration-worktrees!
|
|
294
|
+
"Remove swarm iteration worktree dirs (.w*-i*) and oompa/* branches.
|
|
295
|
+
Returns {:dirs-removed n :branches-removed n}."
|
|
296
|
+
[]
|
|
297
|
+
(let [ls-result (process/sh ["find" "." "-maxdepth" "1" "-type" "d" "-name" ".w*-i*"]
|
|
298
|
+
{:out :string})
|
|
299
|
+
dirs (if (zero? (:exit ls-result))
|
|
300
|
+
(->> (str/split-lines (:out ls-result))
|
|
301
|
+
(remove str/blank?))
|
|
302
|
+
[])
|
|
303
|
+
_ (doseq [dir dirs]
|
|
304
|
+
(remove-stale-worktree! dir (worktree-branch-name dir)))
|
|
305
|
+
br-result (process/sh ["git" "branch" "--list" "oompa/*"] {:out :string})
|
|
306
|
+
branches (if (zero? (:exit br-result))
|
|
307
|
+
(->> (str/split-lines (:out br-result))
|
|
308
|
+
(map str/trim)
|
|
309
|
+
(remove str/blank?))
|
|
310
|
+
[])
|
|
311
|
+
;; Branches may already be deleted by remove-stale-worktree!, so ignore failures.
|
|
312
|
+
_ (doseq [b branches]
|
|
313
|
+
(process/sh ["git" "branch" "-D" b] {:out :string :err :string}))]
|
|
314
|
+
{:dirs-removed (count dirs)
|
|
315
|
+
:branches-removed (count branches)}))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
(defn- probe-model
|
|
319
|
+
"Send 'say ok' to a model via its harness CLI. Returns true if model responds.
|
|
320
|
+
Uses harness/build-probe-cmd for the command.
|
|
321
|
+
For stdin-based harnesses (e.g. claude), delivers the probe prompt via stdin.
|
|
322
|
+
For close-stdin harnesses, uses /dev/null to prevent hang."
|
|
323
|
+
[harness-kw model]
|
|
324
|
+
(try
|
|
325
|
+
(let [cmd (harness/build-probe-cmd harness-kw model)
|
|
326
|
+
probe-prompt "[_HIDE_TEST_] say ok"
|
|
327
|
+
stdin-val (harness/process-stdin harness-kw probe-prompt)
|
|
328
|
+
proc (process/process cmd {:out :string :err :string :in stdin-val})
|
|
329
|
+
result (deref proc 30000 :timeout)]
|
|
330
|
+
(if (= result :timeout)
|
|
331
|
+
(do (.destroyForcibly (:proc proc)) false)
|
|
332
|
+
(zero? (:exit result))))
|
|
333
|
+
(catch Exception _ false)))
|
|
334
|
+
|
|
335
|
+
(defn- validate-models!
|
|
336
|
+
"Probe each unique harness:model pair. Prints results and exits if any fail."
|
|
337
|
+
[worker-configs review-models]
|
|
338
|
+
(let [;; Deduplicate by harness:model only (ignore reasoning level)
|
|
339
|
+
models (into (->> worker-configs
|
|
340
|
+
(map (fn [wc]
|
|
341
|
+
(let [{:keys [harness model]} (parse-model-string (:model wc))]
|
|
342
|
+
{:harness harness :model model})))
|
|
343
|
+
set)
|
|
344
|
+
(map #(select-keys % [:harness :model]) review-models))
|
|
345
|
+
_ (println "Validating models...")
|
|
346
|
+
results (pmap (fn [{:keys [harness model]}]
|
|
347
|
+
(let [ok (probe-model harness model)]
|
|
348
|
+
(println (format " %s:%s %s"
|
|
349
|
+
(name harness) model
|
|
350
|
+
(if ok "OK" "FAIL")))
|
|
351
|
+
{:harness harness :model model :ok ok}))
|
|
352
|
+
models)
|
|
353
|
+
failures (filter (complement :ok) results)]
|
|
354
|
+
(when (seq failures)
|
|
355
|
+
(println)
|
|
356
|
+
(println "ERROR: The following models are not accessible:")
|
|
357
|
+
(doseq [{:keys [harness model]} failures]
|
|
358
|
+
(println (format " %s:%s" (name harness) model)))
|
|
359
|
+
(println)
|
|
360
|
+
(println "Fix model names in oompa.json and retry.")
|
|
361
|
+
(System/exit 1))
|
|
362
|
+
(println)))
|
|
363
|
+
|
|
364
|
+
(def ^:private default-detach-startup-timeout 20)
|
|
365
|
+
|
|
366
|
+
(defn- run-id []
|
|
367
|
+
(subs (str (java.util.UUID/randomUUID)) 0 8))
|
|
368
|
+
|
|
369
|
+
(defn- run-ts []
|
|
370
|
+
(.format (java.time.format.DateTimeFormatter/ofPattern "yyyyMMdd-HHmmss")
|
|
371
|
+
(java.time.LocalDateTime/now)))
|
|
372
|
+
|
|
373
|
+
(defn- default-config-file
|
|
374
|
+
[]
|
|
375
|
+
(cond
|
|
376
|
+
(.exists (io/file "oompa.json")) "oompa.json"
|
|
377
|
+
(.exists (io/file "oompa/oompa.json")) "oompa/oompa.json"
|
|
378
|
+
:else nil))
|
|
379
|
+
|
|
380
|
+
(defn- resolve-config-file
|
|
381
|
+
[opts args]
|
|
382
|
+
(let [candidate (or (:config-file opts)
|
|
383
|
+
(first args)
|
|
384
|
+
(default-config-file))]
|
|
385
|
+
(when candidate
|
|
386
|
+
(.getCanonicalPath (io/file candidate)))))
|
|
387
|
+
|
|
388
|
+
(defn- prepare-log-file!
|
|
389
|
+
"Create oompa/logs and return absolute log path."
|
|
390
|
+
[rid]
|
|
391
|
+
(let [dir (if (.exists (io/file "oompa"))
|
|
392
|
+
(io/file "oompa" "logs")
|
|
393
|
+
(io/file "runs" "logs"))]
|
|
394
|
+
(.mkdirs dir)
|
|
395
|
+
(.getCanonicalPath (io/file dir (str (run-ts) "_" rid ".log")))))
|
|
396
|
+
|
|
397
|
+
(defn- read-file-safe
|
|
398
|
+
[path]
|
|
399
|
+
(try
|
|
400
|
+
(if (.exists (io/file path))
|
|
401
|
+
(slurp path)
|
|
402
|
+
"")
|
|
403
|
+
(catch Exception _
|
|
404
|
+
"")))
|
|
405
|
+
|
|
406
|
+
(defn- tail-lines
|
|
407
|
+
[text n]
|
|
408
|
+
(->> (str/split-lines (or text ""))
|
|
409
|
+
(take-last n)
|
|
410
|
+
(str/join "\n")))
|
|
411
|
+
|
|
412
|
+
(defn- extract-swarm-id
|
|
413
|
+
[text]
|
|
414
|
+
(some->> text
|
|
415
|
+
(re-find #"Swarm ID:\s*([0-9a-f]{8})")
|
|
416
|
+
second))
|
|
417
|
+
|
|
418
|
+
(defn- startup-diagnostic-lines
|
|
419
|
+
[text]
|
|
420
|
+
(->> (str/split-lines (or text ""))
|
|
421
|
+
(filter #(re-find #"ERROR:|FAIL|WARNING:" %))
|
|
422
|
+
(take-last 20)))
|
|
423
|
+
|
|
424
|
+
(defn- print-preflight-warnings!
|
|
425
|
+
[]
|
|
426
|
+
(let [agent-cli? (zero? (:exit (process/sh ["which" "agent-cli"]
|
|
427
|
+
{:out :string :err :string})))]
|
|
428
|
+
(when-not agent-cli?
|
|
429
|
+
(println "WARNING: 'agent-cli' is not on PATH.")
|
|
430
|
+
(println " Model validation may report false model-access failures.")))
|
|
431
|
+
(let [dirty (process/sh ["git" "status" "--porcelain"]
|
|
432
|
+
{:out :string :err :string})
|
|
433
|
+
lines (->> (:out dirty)
|
|
434
|
+
str/split-lines
|
|
435
|
+
(remove str/blank?))]
|
|
436
|
+
(when (seq lines)
|
|
437
|
+
(println (format "WARNING: Git working tree is dirty (%d changed paths)." (count lines)))
|
|
438
|
+
(println " Swarm startup may fail until changes are committed/stashed.")
|
|
439
|
+
(doseq [line (take 20 lines)]
|
|
440
|
+
(println line))
|
|
441
|
+
(when (> (count lines) 20)
|
|
442
|
+
(println (format "... (%d total changed paths)" (count lines)))))))
|
|
443
|
+
|
|
444
|
+
(defn- runtime-classpath-entry
|
|
445
|
+
"Best-effort classpath root for agentnet sources."
|
|
446
|
+
[]
|
|
447
|
+
(or
|
|
448
|
+
(some-> (System/getenv "OOMPA_PACKAGE_ROOT")
|
|
449
|
+
(io/file "agentnet" "src")
|
|
450
|
+
.getCanonicalPath)
|
|
451
|
+
(->> (str/split (or (System/getProperty "java.class.path") "")
|
|
452
|
+
(re-pattern (java.io.File/pathSeparator)))
|
|
453
|
+
(map str/trim)
|
|
454
|
+
(remove str/blank?)
|
|
455
|
+
(map io/file)
|
|
456
|
+
(filter #(.exists %))
|
|
457
|
+
(map #(.getCanonicalPath %))
|
|
458
|
+
(some #(when (str/ends-with? % (str "agentnet" java.io.File/separator "src"))
|
|
459
|
+
%)))
|
|
460
|
+
(.getCanonicalPath (io/file "agentnet" "src"))))
|
|
461
|
+
|
|
462
|
+
(defn- run-classpath
|
|
463
|
+
[]
|
|
464
|
+
(runtime-classpath-entry))
|
|
465
|
+
|
|
466
|
+
(defn- run-script-path
|
|
467
|
+
[]
|
|
468
|
+
(if-let [pkg-root (System/getenv "OOMPA_PACKAGE_ROOT")]
|
|
469
|
+
(.getCanonicalPath (io/file pkg-root "swarm.bb"))
|
|
470
|
+
(let [cp (io/file (runtime-classpath-entry))
|
|
471
|
+
;; cp = <repo>/agentnet/src -> <repo>/swarm.bb
|
|
472
|
+
repo-root (some-> cp .getParentFile .getParentFile)
|
|
473
|
+
candidate (when repo-root (io/file repo-root "swarm.bb"))]
|
|
474
|
+
(if (and candidate (.exists candidate))
|
|
475
|
+
(.getCanonicalPath candidate)
|
|
476
|
+
(.getCanonicalPath (io/file "swarm.bb"))))))
|
|
477
|
+
|
|
478
|
+
(defn- detached-cmd
|
|
479
|
+
[opts config-file]
|
|
480
|
+
(cond-> ["nohup" "bb" "--classpath" (run-classpath) (run-script-path) "swarm"]
|
|
481
|
+
(:dry-run opts) (conj "--dry-run")
|
|
482
|
+
true (conj config-file)))
|
|
483
|
+
|
|
484
|
+
(defn- spawn-detached!
|
|
485
|
+
[cmd log-file]
|
|
486
|
+
(let [log (io/file log-file)
|
|
487
|
+
pb (doto (ProcessBuilder. ^java.util.List cmd)
|
|
488
|
+
(.directory (io/file "."))
|
|
489
|
+
(.redirectInput (java.lang.ProcessBuilder$Redirect/from (io/file "/dev/null")))
|
|
490
|
+
(.redirectOutput (java.lang.ProcessBuilder$Redirect/appendTo log))
|
|
491
|
+
(.redirectError (java.lang.ProcessBuilder$Redirect/appendTo log)))
|
|
492
|
+
proc (.start pb)
|
|
493
|
+
pid (.pid proc)]
|
|
494
|
+
;; Give spawn a short window before validation checks liveness.
|
|
495
|
+
(Thread/sleep 100)
|
|
496
|
+
pid))
|
|
497
|
+
|
|
498
|
+
(defn- pid-alive?
|
|
499
|
+
[pid]
|
|
500
|
+
(zero? (:exit (process/sh ["kill" "-0" (str pid)]
|
|
501
|
+
{:out :string :err :string}))))
|
|
502
|
+
|
|
503
|
+
(defn- wait-for-startup!
|
|
504
|
+
[pid log-file timeout-sec]
|
|
505
|
+
(loop [waited 0]
|
|
506
|
+
(let [content (read-file-safe log-file)
|
|
507
|
+
started? (str/includes? content "Started event written to runs/")
|
|
508
|
+
alive? (pid-alive? pid)]
|
|
509
|
+
(cond
|
|
510
|
+
started?
|
|
511
|
+
{:status :started
|
|
512
|
+
:content content
|
|
513
|
+
:swarm-id (extract-swarm-id content)}
|
|
514
|
+
|
|
515
|
+
(not alive?)
|
|
516
|
+
{:status :failed
|
|
517
|
+
:content content}
|
|
518
|
+
|
|
519
|
+
(>= waited timeout-sec)
|
|
520
|
+
{:status :timeout
|
|
521
|
+
:content content}
|
|
522
|
+
|
|
523
|
+
:else
|
|
524
|
+
(do
|
|
525
|
+
(Thread/sleep 1000)
|
|
526
|
+
(recur (inc waited)))))))
|
|
527
|
+
|
|
528
|
+
(defn- cmd-run-detached
|
|
529
|
+
[opts config-file]
|
|
530
|
+
(print-preflight-warnings!)
|
|
531
|
+
(when-not (.exists (io/file config-file))
|
|
532
|
+
(println (format "ERROR: Config file not found: %s" (.getCanonicalPath (io/file config-file))))
|
|
533
|
+
(println (format " Working directory: %s" (.getCanonicalPath (io/file "."))))
|
|
534
|
+
(println)
|
|
535
|
+
(println "Tip: paths are relative to the working directory. Did you mean:")
|
|
536
|
+
(println (format " oompa run --config oompa/%s" (.getName (io/file config-file))))
|
|
537
|
+
(System/exit 1))
|
|
538
|
+
(let [timeout-sec (or (:startup-timeout opts)
|
|
539
|
+
(parse-int (System/getenv "OOMPA_DETACH_STARTUP_TIMEOUT")
|
|
540
|
+
default-detach-startup-timeout))
|
|
541
|
+
rid (run-id)
|
|
542
|
+
log-file (prepare-log-file! rid)
|
|
543
|
+
cmd (detached-cmd opts config-file)
|
|
544
|
+
pid (spawn-detached! cmd log-file)]
|
|
545
|
+
(println (format "Config: %s" config-file))
|
|
546
|
+
(when (:dry-run opts)
|
|
547
|
+
(println "Merge mode: dry-run"))
|
|
548
|
+
(let [{:keys [status content swarm-id]} (wait-for-startup! pid log-file timeout-sec)]
|
|
549
|
+
(case status
|
|
550
|
+
:failed
|
|
551
|
+
(do
|
|
552
|
+
(println)
|
|
553
|
+
(println "ERROR: Detached swarm exited during startup validation.")
|
|
554
|
+
(println "Startup log excerpt:")
|
|
555
|
+
(println (tail-lines content 120))
|
|
556
|
+
(System/exit 1))
|
|
557
|
+
|
|
558
|
+
:timeout
|
|
559
|
+
(do
|
|
560
|
+
(println)
|
|
561
|
+
(println (format "WARNING: Detached swarm still initializing after %ss." timeout-sec))
|
|
562
|
+
(println "Recent startup log lines:")
|
|
563
|
+
(println (tail-lines content 40)))
|
|
564
|
+
|
|
565
|
+
nil)
|
|
566
|
+
(let [diag (startup-diagnostic-lines content)]
|
|
567
|
+
(when (seq diag)
|
|
568
|
+
(println)
|
|
569
|
+
(println "Startup diagnostics:")
|
|
570
|
+
(doseq [line diag]
|
|
571
|
+
(println line))))
|
|
572
|
+
(println)
|
|
573
|
+
(println " ┌──────────────────────────────────────────────────────────────┐")
|
|
574
|
+
(println " │ OOMPA SWARM RUN (DETACHED) │")
|
|
575
|
+
(println (format " │ Run id: %-46s│" rid))
|
|
576
|
+
(println (format " │ PID: %-46s│" pid))
|
|
577
|
+
(println (format " │ Log file: %-46s│" log-file))
|
|
578
|
+
(println (format " │ Swarm ID: %-46s│" (or swarm-id "(pending)")))
|
|
579
|
+
(println " └──────────────────────────────────────────────────────────────┘")
|
|
580
|
+
(println))))
|
|
581
|
+
|
|
582
|
+
(defn- cmd-run-legacy
|
|
583
|
+
"Run orchestrator once from worker specs (legacy mode)."
|
|
584
|
+
[opts args]
|
|
585
|
+
(let [swarm-id (make-swarm-id)]
|
|
586
|
+
(if-let [specs (:worker-specs opts)]
|
|
587
|
+
;; Mixed worker specs: --workers claude:5 opencode:2
|
|
588
|
+
(let [workers (mapcat
|
|
589
|
+
(fn [spec]
|
|
590
|
+
(let [{:keys [harness count]} spec]
|
|
591
|
+
(map-indexed
|
|
592
|
+
(fn [idx _]
|
|
593
|
+
(worker/create-worker
|
|
594
|
+
{:id (format "%s-%d" (name harness) idx)
|
|
595
|
+
:swarm-id swarm-id
|
|
596
|
+
:harness harness
|
|
597
|
+
:model (:model opts)
|
|
598
|
+
:iterations 1}))
|
|
599
|
+
(range count))))
|
|
600
|
+
specs)]
|
|
601
|
+
(println (format "Running once with mixed workers (swarm %s):" swarm-id))
|
|
602
|
+
(doseq [spec specs]
|
|
603
|
+
(println (format " %dx %s" (:count spec) (name (:harness spec)))))
|
|
604
|
+
(println)
|
|
605
|
+
(worker/run-workers! workers))
|
|
606
|
+
;; Simple mode retired — use oompa.json or --workers harness:count
|
|
607
|
+
(do
|
|
608
|
+
(println "Simple mode is no longer supported. Use oompa.json or --workers harness:count.")
|
|
609
|
+
(System/exit 1)))))
|
|
610
|
+
|
|
611
|
+
(defn cmd-run
|
|
612
|
+
"Run swarm from config. Use --detach for background mode."
|
|
613
|
+
[opts args]
|
|
614
|
+
(if-let [config-file (resolve-config-file opts args)]
|
|
615
|
+
(if (:detach opts)
|
|
616
|
+
(cmd-run-detached opts config-file)
|
|
617
|
+
(cmd-swarm opts [config-file]))
|
|
618
|
+
(cmd-run-legacy opts args)))
|
|
619
|
+
|
|
620
|
+
(defn cmd-loop
|
|
621
|
+
"Run orchestrator N times"
|
|
622
|
+
[opts args]
|
|
623
|
+
(let [swarm-id (make-swarm-id)
|
|
624
|
+
iterations (or (some-> (first args) (parse-int nil))
|
|
625
|
+
(:iterations opts)
|
|
626
|
+
20)]
|
|
627
|
+
(if-let [specs (:worker-specs opts)]
|
|
628
|
+
;; Mixed worker specs: --workers claude:5 opencode:2
|
|
629
|
+
(let [workers (mapcat
|
|
630
|
+
(fn [spec]
|
|
631
|
+
(let [{:keys [harness count]} spec]
|
|
632
|
+
(map-indexed
|
|
633
|
+
(fn [idx _]
|
|
634
|
+
(worker/create-worker
|
|
635
|
+
{:id (format "%s-%d" (name harness) idx)
|
|
636
|
+
:swarm-id swarm-id
|
|
637
|
+
:harness harness
|
|
638
|
+
:model (:model opts)
|
|
639
|
+
:iterations iterations}))
|
|
640
|
+
(range count))))
|
|
641
|
+
specs)]
|
|
642
|
+
(println (format "Starting %d iterations with mixed workers (swarm %s):" iterations swarm-id))
|
|
643
|
+
(doseq [spec specs]
|
|
644
|
+
(println (format " %dx %s" (:count spec) (name (:harness spec)))))
|
|
645
|
+
(println)
|
|
646
|
+
(worker/run-workers! workers))
|
|
647
|
+
;; Simple mode retired — use oompa.json or --workers harness:count
|
|
648
|
+
(do
|
|
649
|
+
(println "Simple mode is no longer supported. Use oompa.json or --workers harness:count.")
|
|
650
|
+
(System/exit 1)))))
|
|
651
|
+
|
|
652
|
+
(defn cmd-prompt
|
|
653
|
+
"Run ad-hoc prompt as single task"
|
|
654
|
+
[opts args]
|
|
655
|
+
(let [prompt-text (str/join " " args)]
|
|
656
|
+
(when (str/blank? prompt-text)
|
|
657
|
+
(println "Error: prompt text required")
|
|
658
|
+
(System/exit 1))
|
|
659
|
+
;; Create temporary task
|
|
660
|
+
(let [task {:id (format "prompt-%d" (System/currentTimeMillis))
|
|
661
|
+
:summary prompt-text
|
|
662
|
+
:targets ["src" "tests" "docs"]
|
|
663
|
+
:priority 1}]
|
|
664
|
+
;; Write to temporary tasks file
|
|
665
|
+
(spit "config/tasks.edn" (pr-str [task]))
|
|
666
|
+
;; Run
|
|
667
|
+
(orchestrator/run-once! opts))))
|
|
668
|
+
|
|
669
|
+
(defn cmd-status
|
|
670
|
+
"Show running swarms."
|
|
671
|
+
[opts args]
|
|
672
|
+
(let [run-ids (runs/list-runs)]
|
|
673
|
+
(if (seq run-ids)
|
|
674
|
+
(let [running (for [id run-ids
|
|
675
|
+
:let [started (runs/read-started id)
|
|
676
|
+
stopped (runs/read-stopped id)
|
|
677
|
+
pid (:pid started)]
|
|
678
|
+
:when (and started (not stopped) pid (pid-alive? pid))]
|
|
679
|
+
{:id id
|
|
680
|
+
:pid pid
|
|
681
|
+
:workers (count (:workers started))
|
|
682
|
+
:work-count (count (runs/list-cycles id))})]
|
|
683
|
+
(if (seq running)
|
|
684
|
+
(do
|
|
685
|
+
(println (format "Running Swarms: %d" (count running)))
|
|
686
|
+
(doseq [r running]
|
|
687
|
+
(println (format " Swarm: %s | PID: %s | Workers: %d | Work Count: %d"
|
|
688
|
+
(:id r) (:pid r) (:workers r) (:work-count r)))))
|
|
689
|
+
(println "No running swarms.")))
|
|
690
|
+
(println "No swarms found."))))
|
|
691
|
+
|
|
692
|
+
(defn cmd-info
|
|
693
|
+
"Show detailed information of a swarm run — reads event-sourced runs/{swarm-id}/ data."
|
|
694
|
+
[opts args]
|
|
695
|
+
(let [run-ids (runs/list-runs)]
|
|
696
|
+
(if (seq run-ids)
|
|
697
|
+
(let [target-ids (if (seq args) [(first args)] run-ids)]
|
|
698
|
+
(doseq [swarm-id target-ids]
|
|
699
|
+
(let [started (runs/read-started swarm-id)
|
|
700
|
+
stopped (runs/read-stopped swarm-id)
|
|
701
|
+
cycles (runs/list-cycles swarm-id)
|
|
702
|
+
reviews (runs/list-reviews swarm-id)]
|
|
703
|
+
(println "--------------------------------------------------")
|
|
704
|
+
(println (format "Swarm: %s" swarm-id))
|
|
705
|
+
(when started
|
|
706
|
+
(println (format " Started: %s" (:started-at started)))
|
|
707
|
+
(println (format " PID: %s" (or (:pid started) "N/A")))
|
|
708
|
+
(println (format " Config: %s" (or (:config-file started) "N/A")))
|
|
709
|
+
(println (format " Workers: %d" (count (:workers started)))))
|
|
710
|
+
(println)
|
|
711
|
+
(if stopped
|
|
712
|
+
(println (format "Stopped: %s (reason: %s%s)"
|
|
713
|
+
(:stopped-at stopped)
|
|
714
|
+
(:reason stopped)
|
|
715
|
+
(if (:error stopped)
|
|
716
|
+
(str ", error: " (:error stopped))
|
|
717
|
+
"")))
|
|
718
|
+
(println " (still running — no stopped event yet)"))
|
|
719
|
+
(when (seq cycles)
|
|
720
|
+
(println)
|
|
721
|
+
(println (format "Cycles: %d total" (count cycles)))
|
|
722
|
+
(doseq [c cycles]
|
|
723
|
+
(println (format " %s-c%d: %s (%dms, claimed: %s)"
|
|
724
|
+
(:worker-id c) (:cycle c)
|
|
725
|
+
(:outcome c)
|
|
726
|
+
(or (:duration-ms c) 0)
|
|
727
|
+
(str/join ", " (or (:claimed-task-ids c) []))))))
|
|
728
|
+
(when (seq reviews)
|
|
729
|
+
(println)
|
|
730
|
+
(println (format "Reviews: %d total" (count reviews)))
|
|
731
|
+
(doseq [r reviews]
|
|
732
|
+
(println (format " %s-c%d-r%d: %s"
|
|
733
|
+
(:worker-id r) (:cycle r) (:round r)
|
|
734
|
+
(:verdict r)))))
|
|
735
|
+
(println))))
|
|
736
|
+
;; Fall back to legacy JSONL format
|
|
737
|
+
(let [runs-dir (io/file "runs")
|
|
738
|
+
files (when (.exists runs-dir)
|
|
739
|
+
(->> (.listFiles runs-dir)
|
|
740
|
+
(filter #(.isFile %))
|
|
741
|
+
(sort-by #(.lastModified %) >)))]
|
|
742
|
+
(if-let [latest (first files)]
|
|
743
|
+
(do
|
|
744
|
+
(println (format "Latest run (legacy): %s" (.getName latest)))
|
|
745
|
+
(println)
|
|
746
|
+
(with-open [r (io/reader latest)]
|
|
747
|
+
(let [entries (mapv #(json/parse-string % true) (line-seq r))
|
|
748
|
+
by-status (group-by :status entries)]
|
|
749
|
+
(doseq [[status tasks] (sort-by first by-status)]
|
|
750
|
+
(println (format "%s: %d" (name status) (count tasks))))
|
|
751
|
+
(println)
|
|
752
|
+
(println (format "Total: %d tasks" (count entries))))))
|
|
753
|
+
(println "No runs found."))))))
|
|
754
|
+
|
|
755
|
+
(def ^:private error-outcomes
|
|
756
|
+
#{"error" "merge-failed" "rejected" "stuck"})
|
|
757
|
+
|
|
758
|
+
(def ^:private terminal-run-outcomes
|
|
759
|
+
#{"merged" "rejected" "error" "merge-failed" "sync-failed" "stuck" "no-changes"})
|
|
760
|
+
|
|
761
|
+
(defn- run-state
|
|
762
|
+
"Derive run lifecycle state from started/stopped events + PID liveness."
|
|
763
|
+
[started stopped]
|
|
764
|
+
(cond
|
|
765
|
+
(nil? started) "missing-started"
|
|
766
|
+
stopped (str "stopped/" (:reason stopped))
|
|
767
|
+
(pid-alive? (:pid started)) "running"
|
|
768
|
+
:else "stale"))
|
|
769
|
+
|
|
770
|
+
(defn- latest-cycles-by-worker
|
|
771
|
+
"Return map of worker-id -> latest cycle entry."
|
|
772
|
+
[cycles]
|
|
773
|
+
(reduce (fn [acc c]
|
|
774
|
+
(let [wid (:worker-id c)
|
|
775
|
+
prev (get acc wid)]
|
|
776
|
+
(if (or (nil? prev)
|
|
777
|
+
(> (or (:cycle c) 0) (or (:cycle prev) 0)))
|
|
778
|
+
(assoc acc wid c)
|
|
779
|
+
acc)))
|
|
780
|
+
{}
|
|
781
|
+
cycles))
|
|
782
|
+
|
|
783
|
+
(defn- worker-runtime
|
|
784
|
+
"Best-effort worker runtime classification for view output."
|
|
785
|
+
[worker latest-cycle worker-cycles run-state*]
|
|
786
|
+
(let [run-max (or (:runs worker) (:iterations worker) 0)
|
|
787
|
+
runs-done (count (filter #(terminal-run-outcomes (:outcome %)) worker-cycles))
|
|
788
|
+
outcome (or (:outcome latest-cycle) "-")]
|
|
789
|
+
(cond
|
|
790
|
+
(>= runs-done run-max) "completed"
|
|
791
|
+
(str/starts-with? run-state* "stopped/") "stopped"
|
|
792
|
+
(= run-state* "stale") "stale"
|
|
793
|
+
(nil? latest-cycle) "starting"
|
|
794
|
+
(= outcome "working") "working"
|
|
795
|
+
(= outcome "executor-done") "idle"
|
|
796
|
+
:else outcome)))
|
|
797
|
+
|
|
798
|
+
(defn- model-label
|
|
799
|
+
[{:keys [harness model reasoning]}]
|
|
800
|
+
(str harness ":" model (when reasoning (str ":" reasoning))))
|
|
801
|
+
|
|
802
|
+
(defn- run-metrics
|
|
803
|
+
"Summarize cycle metrics for a run."
|
|
804
|
+
[cycles]
|
|
805
|
+
(let [merged (count (filter #(= "merged" (:outcome %)) cycles))
|
|
806
|
+
failed (count (filter #(error-outcomes (:outcome %)) cycles))
|
|
807
|
+
claimed-all (->> cycles
|
|
808
|
+
(mapcat #(or (:claimed-task-ids %) []))
|
|
809
|
+
(remove str/blank?))
|
|
810
|
+
completed-ids (->> cycles
|
|
811
|
+
(filter #(= "merged" (:outcome %)))
|
|
812
|
+
(mapcat #(or (:claimed-task-ids %) []))
|
|
813
|
+
(remove str/blank?)
|
|
814
|
+
set)]
|
|
815
|
+
{:merged merged
|
|
816
|
+
:failed failed
|
|
817
|
+
:claimed (count (set claimed-all))
|
|
818
|
+
:completed (count completed-ids)}))
|
|
819
|
+
|
|
820
|
+
(defn- cmd-view-one
|
|
821
|
+
[swarm-id]
|
|
822
|
+
(if-let [started (runs/read-started swarm-id)]
|
|
823
|
+
(let [stopped (runs/read-stopped swarm-id)
|
|
824
|
+
cycles (or (runs/list-cycles swarm-id) [])
|
|
825
|
+
reviews (or (runs/list-reviews swarm-id) [])
|
|
826
|
+
workers (or (:workers started) [])
|
|
827
|
+
run-state* (run-state started stopped)
|
|
828
|
+
metrics (run-metrics cycles)
|
|
829
|
+
latest-by-worker (latest-cycles-by-worker cycles)
|
|
830
|
+
cycles-by-worker (group-by :worker-id cycles)]
|
|
831
|
+
(println (format "Swarm: %s" swarm-id))
|
|
832
|
+
(println (format "State: %s" run-state*))
|
|
833
|
+
(println (format "Started: %s" (:started-at started)))
|
|
834
|
+
(println (format "PID: %s" (or (:pid started) "N/A")))
|
|
835
|
+
(println (format "Config: %s" (or (:config-file started) "N/A")))
|
|
836
|
+
(when stopped
|
|
837
|
+
(println (format "Stopped: %s" (:stopped-at stopped))))
|
|
838
|
+
(println (format "Cycles: %d" (count cycles)))
|
|
839
|
+
(println (format "PRs: merged=%d failed=%d" (:merged metrics) (:failed metrics)))
|
|
840
|
+
(println (format "Tasks: claimed=%d completed=%d created=n/a"
|
|
841
|
+
(:claimed metrics) (:completed metrics)))
|
|
842
|
+
(println (format "Reviews: %d" (count reviews)))
|
|
843
|
+
(println)
|
|
844
|
+
(println "Workers:")
|
|
845
|
+
(println "ID | Runtime | Runs | Cycles | Last Outcome | Claimed | Model")
|
|
846
|
+
(println "----+-----------+--------+---------+----------------+---------+------------------------------")
|
|
847
|
+
(doseq [w (sort-by :id workers)]
|
|
848
|
+
(let [wid (:id w)
|
|
849
|
+
latest (get latest-by-worker wid)
|
|
850
|
+
worker-cycles (or (get cycles-by-worker wid) [])
|
|
851
|
+
run-max (or (:runs w) (:iterations w) 0)
|
|
852
|
+
runs-done (count (filter #(terminal-run-outcomes (:outcome %)) worker-cycles))
|
|
853
|
+
cycles-done (or (:cycle latest) 0)
|
|
854
|
+
runtime (worker-runtime w latest worker-cycles run-state*)
|
|
855
|
+
outcome (or (:outcome latest) "-")
|
|
856
|
+
claimed (count (or (:claimed-task-ids latest) []))]
|
|
857
|
+
(println (format "%-3s | %-9s | %4d/%-3d | %7d | %-14s | %-7d | %s"
|
|
858
|
+
wid runtime runs-done run-max cycles-done outcome claimed (model-label w))))))
|
|
859
|
+
(do
|
|
860
|
+
(println (format "Swarm not found: %s" swarm-id))
|
|
861
|
+
(System/exit 1))))
|
|
862
|
+
|
|
863
|
+
(defn cmd-list
|
|
864
|
+
"List recent swarms with liveness + activity metrics.
|
|
865
|
+
Default: 20 most recent. Use --all for full history."
|
|
866
|
+
[opts args]
|
|
867
|
+
(let [run-ids (or (runs/list-runs) [])]
|
|
868
|
+
(if-not (seq run-ids)
|
|
869
|
+
(println "No swarm runs found.")
|
|
870
|
+
(let [shown (if (:all opts) run-ids (take 20 run-ids))]
|
|
871
|
+
(println "Swarm Runs:")
|
|
872
|
+
(println "ID | State | PID | Workers | Active | Cycles | Merged | Failed | Done | Started")
|
|
873
|
+
(println "---------+------------------+--------+---------+--------+--------+--------+--------+------+-------------------------")
|
|
874
|
+
(doseq [rid shown]
|
|
875
|
+
(let [started (runs/read-started rid)
|
|
876
|
+
stopped (runs/read-stopped rid)
|
|
877
|
+
cycles (or (runs/list-cycles rid) [])
|
|
878
|
+
workers (or (:workers started) [])
|
|
879
|
+
metrics (run-metrics cycles)
|
|
880
|
+
latest-by-worker (latest-cycles-by-worker cycles)
|
|
881
|
+
cycles-by-worker (group-by :worker-id cycles)
|
|
882
|
+
state* (run-state started stopped)
|
|
883
|
+
active-count (if (= state* "running")
|
|
884
|
+
(count (filter (fn [w]
|
|
885
|
+
(let [wid (:id w)
|
|
886
|
+
run-max (or (:runs w) (:iterations w) 0)
|
|
887
|
+
runs-done (count (filter #(terminal-run-outcomes (:outcome %))
|
|
888
|
+
(or (get cycles-by-worker wid) [])))]
|
|
889
|
+
(< runs-done run-max)))
|
|
890
|
+
workers))
|
|
891
|
+
0)]
|
|
892
|
+
(println (format "%-8s | %-16s | %-6s | %7d | %6d | %6d | %6d | %6d | %4d | %s"
|
|
893
|
+
rid
|
|
894
|
+
state*
|
|
895
|
+
(or (:pid started) "-")
|
|
896
|
+
(count workers)
|
|
897
|
+
active-count
|
|
898
|
+
(count cycles)
|
|
899
|
+
(:merged metrics)
|
|
900
|
+
(:failed metrics)
|
|
901
|
+
(:completed metrics)
|
|
902
|
+
(or (:started-at started) "-")))))
|
|
903
|
+
(when (and (not (:all opts)) (> (count run-ids) 20))
|
|
904
|
+
(println (format "\nShowing 20 of %d runs. Use --all for full history." (count run-ids))))
|
|
905
|
+
(println)
|
|
906
|
+
(println "Use `oompa view <swarm-id>` for detailed single-swarm info.")))))
|
|
907
|
+
|
|
908
|
+
(defn cmd-view
|
|
909
|
+
"Show detailed runtime for one swarm (default: latest run)."
|
|
910
|
+
[opts args]
|
|
911
|
+
(if-let [swarm-id (or (first args) (first (runs/list-runs)))]
|
|
912
|
+
(cmd-view-one swarm-id)
|
|
913
|
+
(println "No swarm runs found.")))
|
|
914
|
+
|
|
915
|
+
(defn cmd-worktrees
|
|
916
|
+
"List worktree status"
|
|
917
|
+
[opts args]
|
|
918
|
+
(let [state-file (io/file ".workers/state.edn")]
|
|
919
|
+
(if (.exists state-file)
|
|
920
|
+
(let [pool (read-string (slurp state-file))
|
|
921
|
+
pool' (worktree/list-worktrees pool)]
|
|
922
|
+
(println "Worktrees:")
|
|
923
|
+
(doseq [{:keys [id path status current-task]} pool']
|
|
924
|
+
(println (format " %s: %s%s"
|
|
925
|
+
id
|
|
926
|
+
(name status)
|
|
927
|
+
(if current-task
|
|
928
|
+
(str " [" current-task "]")
|
|
929
|
+
"")))))
|
|
930
|
+
(println "No worktrees initialized."))))
|
|
931
|
+
|
|
932
|
+
(defn cmd-cleanup
|
|
933
|
+
"Remove all worktrees (legacy pool + swarm iteration worktrees)."
|
|
934
|
+
[opts args]
|
|
935
|
+
(let [state-file (io/file ".workers/state.edn")]
|
|
936
|
+
(println "Removing worktrees...")
|
|
937
|
+
(if (.exists state-file)
|
|
938
|
+
(let [pool (read-string (slurp state-file))]
|
|
939
|
+
(worktree/cleanup-pool! pool))
|
|
940
|
+
(println "No legacy pool worktrees to clean up."))
|
|
941
|
+
(let [{:keys [dirs-removed branches-removed]} (cleanup-iteration-worktrees!)]
|
|
942
|
+
(println (format "Removed %d swarm worktree dir(s) and %d oompa branch(es)."
|
|
943
|
+
dirs-removed branches-removed)))
|
|
944
|
+
(println "Done.")))
|
|
945
|
+
|
|
946
|
+
(defn cmd-context
|
|
947
|
+
"Print current context (for debugging prompts)"
|
|
948
|
+
[opts args]
|
|
949
|
+
(let [ctx (orchestrator/build-context [])]
|
|
950
|
+
(println (:context_header ctx))))
|
|
951
|
+
|
|
952
|
+
(defn cmd-check
|
|
953
|
+
"Check if agent backends are available"
|
|
954
|
+
[opts args]
|
|
955
|
+
(println "Checking agent backends...")
|
|
956
|
+
(doseq [harness-kw (sort (harness/known-harnesses))]
|
|
957
|
+
(let [available? (harness/check-available harness-kw)]
|
|
958
|
+
(println (format " %s: %s"
|
|
959
|
+
(name harness-kw)
|
|
960
|
+
(if available? "✓ available" "✗ not found"))))))
|
|
961
|
+
|
|
962
|
+
(def ^:private reasoning-variants
|
|
963
|
+
#{"minimal" "low" "medium" "high" "max" "xhigh"})
|
|
964
|
+
|
|
965
|
+
(defn- parse-model-string
|
|
966
|
+
"Parse model string into {:harness :model :reasoning}.
|
|
967
|
+
|
|
968
|
+
Supported formats:
|
|
969
|
+
- harness:model
|
|
970
|
+
- harness:model:reasoning (if reasoning is in reasoning-variants)
|
|
971
|
+
- model (defaults harness to :codex)
|
|
972
|
+
|
|
973
|
+
Note: model identifiers may contain ':' (for example openrouter/...:free).
|
|
974
|
+
Those suffixes are preserved in :model if not a known reasoning variant."
|
|
975
|
+
[s]
|
|
976
|
+
(if (and s (str/includes? s ":"))
|
|
977
|
+
(let [[harness-str rest*] (str/split s #":" 2)
|
|
978
|
+
harness (keyword harness-str)]
|
|
979
|
+
(if (harness/valid-harness? harness)
|
|
980
|
+
;; Check for reasoning suffix for any valid harness
|
|
981
|
+
(if-let [idx (str/last-index-of rest* ":")]
|
|
982
|
+
(let [model* (subs rest* 0 idx)
|
|
983
|
+
reasoning* (subs rest* (inc idx))]
|
|
984
|
+
(if (contains? reasoning-variants reasoning*)
|
|
985
|
+
{:harness harness :model model* :reasoning reasoning*}
|
|
986
|
+
{:harness harness :model rest*}))
|
|
987
|
+
{:harness harness :model rest*})
|
|
988
|
+
;; Not a known harness prefix, treat as raw model on default harness.
|
|
989
|
+
{:harness :codex :model s}))
|
|
990
|
+
{:harness :codex :model s}))
|
|
991
|
+
|
|
992
|
+
(defn- parse-reviewer-entry
|
|
993
|
+
"Parse reviewer config entry from either:
|
|
994
|
+
1) string model spec: \"harness:model[:reasoning]\"
|
|
995
|
+
2) map: {:model \"...\" :prompt \"path\"|[...]}.
|
|
996
|
+
Returns nil for invalid entries."
|
|
997
|
+
[entry]
|
|
998
|
+
(cond
|
|
999
|
+
(string? entry)
|
|
1000
|
+
(parse-model-string entry)
|
|
1001
|
+
|
|
1002
|
+
(map? entry)
|
|
1003
|
+
(let [model (:model entry)]
|
|
1004
|
+
(when (string? model)
|
|
1005
|
+
(let [parsed (parse-model-string model)
|
|
1006
|
+
prompts (let [p (:prompt entry)]
|
|
1007
|
+
(cond
|
|
1008
|
+
(vector? p) p
|
|
1009
|
+
(string? p) [p]
|
|
1010
|
+
:else []))]
|
|
1011
|
+
(assoc parsed :prompts prompts))))
|
|
1012
|
+
|
|
1013
|
+
:else
|
|
1014
|
+
nil))
|
|
1015
|
+
|
|
1016
|
+
(defn cmd-swarm
|
|
1017
|
+
"Run multiple worker configs from oompa.json in parallel"
|
|
1018
|
+
[opts args]
|
|
1019
|
+
(let [config-file (or (first args) "oompa.json")
|
|
1020
|
+
f (io/file config-file)
|
|
1021
|
+
swarm-id (make-swarm-id)]
|
|
1022
|
+
(when-not (.exists f)
|
|
1023
|
+
(println (format "ERROR: Config file not found: %s" (.getCanonicalPath f)))
|
|
1024
|
+
(println (format " Working directory: %s" (.getCanonicalPath (io/file "."))))
|
|
1025
|
+
(println)
|
|
1026
|
+
(println "Tip: paths are relative to the working directory. Did you mean:")
|
|
1027
|
+
(println (format " oompa run --config oompa/%s" (.getName f)))
|
|
1028
|
+
(System/exit 1))
|
|
1029
|
+
;; Preflight: abort if git is dirty to prevent merge conflicts
|
|
1030
|
+
(check-git-clean!)
|
|
1031
|
+
|
|
1032
|
+
(let [config (json/parse-string (slurp f) true)
|
|
1033
|
+
;; Parse reviewer config — supports legacy + new formats.
|
|
1034
|
+
generic-reviewers (cond
|
|
1035
|
+
(:review_models config)
|
|
1036
|
+
(->> (:review_models config)
|
|
1037
|
+
(map parse-reviewer-entry)
|
|
1038
|
+
(remove nil?)
|
|
1039
|
+
vec)
|
|
1040
|
+
|
|
1041
|
+
(:review_model config)
|
|
1042
|
+
(->> [(:review_model config)]
|
|
1043
|
+
(map parse-reviewer-entry)
|
|
1044
|
+
(remove nil?)
|
|
1045
|
+
vec)
|
|
1046
|
+
|
|
1047
|
+
(:reviewer config)
|
|
1048
|
+
(->> [(:reviewer config)]
|
|
1049
|
+
(map parse-reviewer-entry)
|
|
1050
|
+
(remove nil?)
|
|
1051
|
+
vec)
|
|
1052
|
+
|
|
1053
|
+
:else [])
|
|
1054
|
+
|
|
1055
|
+
;; Parse planner config — optional dedicated planner
|
|
1056
|
+
;; Runs in project root, no worktree/review/merge, respects max_pending backpressure
|
|
1057
|
+
planner-config (:planner config)
|
|
1058
|
+
planner-parsed (when planner-config
|
|
1059
|
+
(let [parsed (parse-model-string (:model planner-config))
|
|
1060
|
+
prompts (let [p (:prompt planner-config)]
|
|
1061
|
+
(cond (vector? p) p
|
|
1062
|
+
(string? p) [p]
|
|
1063
|
+
:else []))]
|
|
1064
|
+
(assoc parsed
|
|
1065
|
+
:prompts prompts
|
|
1066
|
+
:max-pending (or (:max_pending planner-config) 10))))
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
worker-configs (:workers config)
|
|
1070
|
+
|
|
1071
|
+
;; Require max_cycle to be present on all workers
|
|
1072
|
+
_ (doseq [[idx wc] (map-indexed vector worker-configs)]
|
|
1073
|
+
(when-not (:max_cycle wc)
|
|
1074
|
+
(println (format "ERROR: Worker %d missing 'max_cycle' in config. 'iterations' has been deprecated in favor of 'max_cycle' for defining the number of full worker loops." idx))
|
|
1075
|
+
(System/exit 1)))
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
;; Expand worker configs by count
|
|
1079
|
+
expanded-workers (mapcat (fn [wc]
|
|
1080
|
+
(let [cnt (or (:count wc) 1)]
|
|
1081
|
+
(repeat cnt (dissoc wc :count))))
|
|
1082
|
+
worker-configs)
|
|
1083
|
+
|
|
1084
|
+
;; Convert to worker format
|
|
1085
|
+
workers (map-indexed
|
|
1086
|
+
(fn [idx wc]
|
|
1087
|
+
(let [{:keys [harness model reasoning]} (parse-model-string (:model wc))
|
|
1088
|
+
;; Support per-worker reviewer override (legacy + new):
|
|
1089
|
+
;; - review_model: "harness:model"
|
|
1090
|
+
;; - review_models: ["harness:model", ...]
|
|
1091
|
+
;; - reviewer: {model, prompt}
|
|
1092
|
+
;; - reviewers: [string|map, ...]
|
|
1093
|
+
worker-reviewers (cond
|
|
1094
|
+
(:reviewers wc)
|
|
1095
|
+
(->> (:reviewers wc)
|
|
1096
|
+
(map parse-reviewer-entry)
|
|
1097
|
+
(remove nil?)
|
|
1098
|
+
vec)
|
|
1099
|
+
|
|
1100
|
+
(:review_models wc)
|
|
1101
|
+
(->> (:review_models wc)
|
|
1102
|
+
(map parse-reviewer-entry)
|
|
1103
|
+
(remove nil?)
|
|
1104
|
+
vec)
|
|
1105
|
+
|
|
1106
|
+
(:reviewer wc)
|
|
1107
|
+
(->> [(:reviewer wc)]
|
|
1108
|
+
(map parse-reviewer-entry)
|
|
1109
|
+
(remove nil?)
|
|
1110
|
+
vec)
|
|
1111
|
+
|
|
1112
|
+
(:review_model wc)
|
|
1113
|
+
(->> [(:review_model wc)]
|
|
1114
|
+
(map parse-reviewer-entry)
|
|
1115
|
+
(remove nil?)
|
|
1116
|
+
vec)
|
|
1117
|
+
|
|
1118
|
+
:else [])
|
|
1119
|
+
all-reviewers (->> (concat worker-reviewers generic-reviewers)
|
|
1120
|
+
(map #(select-keys % [:harness :model :reasoning :prompts]))
|
|
1121
|
+
(distinct)
|
|
1122
|
+
(vec))]
|
|
1123
|
+
(worker/create-worker
|
|
1124
|
+
{:id (str "w" idx)
|
|
1125
|
+
:swarm-id swarm-id
|
|
1126
|
+
:harness harness
|
|
1127
|
+
:model model
|
|
1128
|
+
:reasoning reasoning
|
|
1129
|
+
:runs (or (:runs wc) (:max_working_resumes wc) 10)
|
|
1130
|
+
:max-cycles (:max_cycle wc)
|
|
1131
|
+
:iterations (or (:max_working_resumes wc) 10)
|
|
1132
|
+
:prompts (:prompt wc)
|
|
1133
|
+
:can-plan (:can_plan wc)
|
|
1134
|
+
:wait-between (:wait_between wc)
|
|
1135
|
+
:max-wait-for-tasks (:max_wait_for_tasks wc)
|
|
1136
|
+
:max-working-resumes (:max_working_resumes wc)
|
|
1137
|
+
:reviewers all-reviewers})))
|
|
1138
|
+
expanded-workers)]
|
|
1139
|
+
|
|
1140
|
+
;; Preflight: handle stale worktrees from prior runs before launching workers.
|
|
1141
|
+
;; Empty ones are auto-cleaned silently; dirty ones trigger an interactive review.
|
|
1142
|
+
(handle-stale-worktrees! generic-reviewers)
|
|
1143
|
+
|
|
1144
|
+
(println (format "Swarm config from %s:" config-file))
|
|
1145
|
+
(println (format " Swarm ID: %s" swarm-id))
|
|
1146
|
+
(when planner-parsed
|
|
1147
|
+
(println (format " Planner: %s:%s (max_pending: %d%s)"
|
|
1148
|
+
(name (:harness planner-parsed))
|
|
1149
|
+
(:model planner-parsed)
|
|
1150
|
+
(:max-pending planner-parsed)
|
|
1151
|
+
(if (seq (:prompts planner-parsed))
|
|
1152
|
+
(str ", prompts: " (str/join ", " (:prompts planner-parsed)))
|
|
1153
|
+
""))))
|
|
1154
|
+
(when (seq generic-reviewers)
|
|
1155
|
+
(println (format " Generic Reviewers: %s"
|
|
1156
|
+
(str/join ", " (map #(str (name (:harness %)) ":" (:model %)) generic-reviewers)))))
|
|
1157
|
+
(println (format " Workers: %d total" (count workers)))
|
|
1158
|
+
(doseq [[idx wc] (map-indexed vector worker-configs)]
|
|
1159
|
+
(let [{:keys [harness model reasoning]} (parse-model-string (:model wc))]
|
|
1160
|
+
(println (format " - %dx %s:%s%s (%d runs, %d cycle cap%s)"
|
|
1161
|
+
(or (:count wc) 1)
|
|
1162
|
+
(name harness)
|
|
1163
|
+
model
|
|
1164
|
+
(if reasoning (str ":" reasoning) "")
|
|
1165
|
+
(or (:runs wc) (:max_working_resumes wc) 10)
|
|
1166
|
+
(or (:max_cycles wc) (:max_working_resumes wc) (:runs wc) 10)
|
|
1167
|
+
(if (:prompt wc) (str ", " (:prompt wc)) "")))))
|
|
1168
|
+
(println)
|
|
1169
|
+
|
|
1170
|
+
;; Preflight: probe each unique model before launching workers
|
|
1171
|
+
;; Include planner model in validation if configured
|
|
1172
|
+
(validate-models! (cond-> worker-configs
|
|
1173
|
+
planner-config (conj planner-config))
|
|
1174
|
+
generic-reviewers)
|
|
1175
|
+
|
|
1176
|
+
;; Write started event to runs/{swarm-id}/started.json
|
|
1177
|
+
(runs/write-started! swarm-id
|
|
1178
|
+
{:workers workers
|
|
1179
|
+
:planner-config planner-parsed
|
|
1180
|
+
:reviewer-configs generic-reviewers
|
|
1181
|
+
:config-file config-file})
|
|
1182
|
+
(println (format "\nStarted event written to runs/%s/started.json" swarm-id))
|
|
1183
|
+
|
|
1184
|
+
;; Run planner if configured — synchronously before workers
|
|
1185
|
+
(when planner-parsed
|
|
1186
|
+
(println)
|
|
1187
|
+
(println (format " Planner: %s:%s (max_pending: %d)"
|
|
1188
|
+
(name (:harness planner-parsed))
|
|
1189
|
+
(:model planner-parsed)
|
|
1190
|
+
(:max-pending planner-parsed)))
|
|
1191
|
+
(worker/run-planner! (assoc planner-parsed :swarm-id swarm-id)))
|
|
1192
|
+
|
|
1193
|
+
;; Run workers using new worker module
|
|
1194
|
+
(worker/run-workers! workers))))
|
|
1195
|
+
|
|
1196
|
+
(defn cmd-tasks
|
|
1197
|
+
"Show task status"
|
|
1198
|
+
[opts args]
|
|
1199
|
+
(tasks/ensure-dirs!)
|
|
1200
|
+
(let [status (tasks/status-summary)]
|
|
1201
|
+
(println "Task Status:")
|
|
1202
|
+
(println (format " Pending: %d" (:pending status)))
|
|
1203
|
+
(println (format " Current: %d" (:current status)))
|
|
1204
|
+
(println (format " Complete: %d" (:complete status)))
|
|
1205
|
+
(println)
|
|
1206
|
+
(when (pos? (:pending status))
|
|
1207
|
+
(println "Pending tasks:")
|
|
1208
|
+
(doseq [t (tasks/list-pending)]
|
|
1209
|
+
(println (format " - %s: %s" (:id t) (:summary t)))))
|
|
1210
|
+
(when (pos? (:current status))
|
|
1211
|
+
(println "In-progress tasks:")
|
|
1212
|
+
(doseq [t (tasks/list-current)]
|
|
1213
|
+
(println (format " - %s: %s" (:id t) (:summary t)))))))
|
|
1214
|
+
|
|
1215
|
+
(defn- find-latest-swarm-id
|
|
1216
|
+
"Find the most recent swarm ID from runs/ directory."
|
|
1217
|
+
[]
|
|
1218
|
+
(first (runs/list-runs)))
|
|
1219
|
+
|
|
1220
|
+
(defn- read-swarm-pid
|
|
1221
|
+
"Read PID from started.json for a swarm. Returns nil if not found."
|
|
1222
|
+
[swarm-id]
|
|
1223
|
+
(when-let [started (runs/read-started swarm-id)]
|
|
1224
|
+
(:pid started)))
|
|
1225
|
+
|
|
1226
|
+
(defn- pid-alive?
|
|
1227
|
+
"Check if a process is alive via kill -0."
|
|
1228
|
+
[pid]
|
|
1229
|
+
(try
|
|
1230
|
+
(zero? (:exit (process/sh ["kill" "-0" (str pid)]
|
|
1231
|
+
{:out :string :err :string})))
|
|
1232
|
+
(catch Exception _ false)))
|
|
1233
|
+
|
|
1234
|
+
(defn cmd-stop
|
|
1235
|
+
"Send SIGTERM to running swarm — workers finish current cycle then exit"
|
|
1236
|
+
[opts args]
|
|
1237
|
+
(let [swarm-id (or (first args) (find-latest-swarm-id))]
|
|
1238
|
+
(if-not swarm-id
|
|
1239
|
+
(println "No swarm runs found.")
|
|
1240
|
+
(let [stopped (runs/read-stopped swarm-id)]
|
|
1241
|
+
(if stopped
|
|
1242
|
+
(println (format "Swarm %s already stopped (reason: %s)" swarm-id (:reason stopped)))
|
|
1243
|
+
(let [pid (read-swarm-pid swarm-id)]
|
|
1244
|
+
(if-not pid
|
|
1245
|
+
(println (format "No PID found for swarm %s" swarm-id))
|
|
1246
|
+
(if-not (pid-alive? pid)
|
|
1247
|
+
(do
|
|
1248
|
+
(println (format "Swarm %s PID %s is not running (stale). Writing stopped event." swarm-id pid))
|
|
1249
|
+
(runs/write-stopped! swarm-id :interrupted))
|
|
1250
|
+
(do
|
|
1251
|
+
(println (format "Sending SIGTERM to swarm %s (PID %s)..." swarm-id pid))
|
|
1252
|
+
(println "Workers will finish their current cycle and exit.")
|
|
1253
|
+
(process/sh ["kill" (str pid)]))))))))))
|
|
1254
|
+
|
|
1255
|
+
(defn cmd-kill
|
|
1256
|
+
"Send SIGKILL to running swarm — immediate termination"
|
|
1257
|
+
[opts args]
|
|
1258
|
+
(let [swarm-id (or (first args) (find-latest-swarm-id))]
|
|
1259
|
+
(if-not swarm-id
|
|
1260
|
+
(println "No swarm runs found.")
|
|
1261
|
+
(let [stopped (runs/read-stopped swarm-id)]
|
|
1262
|
+
(if stopped
|
|
1263
|
+
(println (format "Swarm %s already stopped (reason: %s)" swarm-id (:reason stopped)))
|
|
1264
|
+
(let [pid (read-swarm-pid swarm-id)]
|
|
1265
|
+
(if-not pid
|
|
1266
|
+
(println (format "No PID found for swarm %s" swarm-id))
|
|
1267
|
+
(if-not (pid-alive? pid)
|
|
1268
|
+
(do
|
|
1269
|
+
(println (format "Swarm %s PID %s is not running (stale). Writing stopped event." swarm-id pid))
|
|
1270
|
+
(runs/write-stopped! swarm-id :interrupted))
|
|
1271
|
+
(do
|
|
1272
|
+
(println (format "Sending SIGKILL to swarm %s (PID %s)..." swarm-id pid))
|
|
1273
|
+
;; SIGKILL bypasses JVM shutdown hooks, so write stopped.json here
|
|
1274
|
+
(process/sh ["kill" "-9" (str pid)])
|
|
1275
|
+
(runs/write-stopped! swarm-id :interrupted)
|
|
1276
|
+
(println "Swarm killed."))))))))))
|
|
1277
|
+
|
|
1278
|
+
(defn cmd-help
|
|
1279
|
+
"Print usage information"
|
|
1280
|
+
[opts args]
|
|
1281
|
+
(println "AgentNet Orchestrator")
|
|
1282
|
+
(println)
|
|
1283
|
+
(println "Usage: ./swarm.bb <command> [options]")
|
|
1284
|
+
(println)
|
|
1285
|
+
(println "Commands:")
|
|
1286
|
+
(println " run [file] Run swarm from config (default: oompa.json, oompa/oompa.json)")
|
|
1287
|
+
(println " loop N Run N iterations")
|
|
1288
|
+
(println " swarm [file] Run multiple worker configs from oompa.json (parallel)")
|
|
1289
|
+
(println " tasks Show task status (pending/current/complete)")
|
|
1290
|
+
(println " prompt \"...\" Run ad-hoc prompt")
|
|
1291
|
+
(println " status Show running swarms")
|
|
1292
|
+
(println " info Show detailed summary of the last run")
|
|
1293
|
+
(println " list List recent swarms (default: 20, --all for full history)")
|
|
1294
|
+
(println " view [swarm-id] Show detailed single-swarm runtime (default: latest)")
|
|
1295
|
+
(println " worktrees List worktree status")
|
|
1296
|
+
(println " stop [swarm-id] Stop swarm gracefully (finish current cycle)")
|
|
1297
|
+
(println " kill [swarm-id] Kill swarm immediately (SIGKILL)")
|
|
1298
|
+
(println " cleanup Remove all worktrees")
|
|
1299
|
+
(println " context Print context block")
|
|
1300
|
+
(println " check Check agent backends")
|
|
1301
|
+
(println " help Show this help")
|
|
1302
|
+
(println " docs Dump all core architecture and swarm design docs")
|
|
1303
|
+
(println)
|
|
1304
|
+
(println "Options:")
|
|
1305
|
+
(println " --workers N Number of parallel workers (default: 2)")
|
|
1306
|
+
(println " --workers H:N [H:N ...] Mixed workers by harness (e.g., claude:5 opencode:2)")
|
|
1307
|
+
(println " --all Show full history for list command")
|
|
1308
|
+
(println " --config PATH Config file for run/swarm")
|
|
1309
|
+
(println " --detach Run in background (run command)")
|
|
1310
|
+
(println " --startup-timeout N Detached startup validation window in seconds")
|
|
1311
|
+
(println " --iterations N Number of iterations per worker (default: 1)")
|
|
1312
|
+
(println (str " --harness {" (str/join "," (map name (sort harnesses))) "} Agent harness to use (default: codex)"))
|
|
1313
|
+
(println " --model MODEL Model to use (e.g., codex:gpt-5.3-codex:medium, claude:opus, gemini:gemini-3-pro-preview)")
|
|
1314
|
+
(println " --dry-run Skip actual merges")
|
|
1315
|
+
(println " --keep-worktrees Don't cleanup worktrees after run")
|
|
1316
|
+
(println)
|
|
1317
|
+
(println "Examples:")
|
|
1318
|
+
(println " ./swarm.bb list")
|
|
1319
|
+
(println " ./swarm.bb list --all")
|
|
1320
|
+
(println " ./swarm.bb view 6cd50f5a")
|
|
1321
|
+
(println " ./swarm.bb run --detach --config oompa/oompa_overnight_self_healing.json")
|
|
1322
|
+
(println " ./swarm.bb loop 10 --harness codex --model gpt-5.3-codex --workers 3")
|
|
1323
|
+
(println " ./swarm.bb loop --workers claude:5 opencode:2 --iterations 20")
|
|
1324
|
+
(println " ./swarm.bb swarm oompa.json # Run multi-model config"))
|
|
1325
|
+
|
|
1326
|
+
(defn cmd-docs
|
|
1327
|
+
"Dump core architecture and design documents"
|
|
1328
|
+
[opts args]
|
|
1329
|
+
(let [docs-dir "docs"
|
|
1330
|
+
core-docs ["SWARM_PHILOSOPHY.md" "SWARM_GUIDE.md" "EDN_TICKETS.md" "SYSTEMS_DESIGN.md" "OOMPA.md"]
|
|
1331
|
+
package-dir (or (System/getenv "OOMPA_PACKAGE_ROOT") ".")
|
|
1332
|
+
doc-paths (map #(str package-dir "/" docs-dir "/" %) core-docs)]
|
|
1333
|
+
(println "# Oompa Loompas Core Documentation")
|
|
1334
|
+
(println)
|
|
1335
|
+
(doseq [path doc-paths]
|
|
1336
|
+
(try
|
|
1337
|
+
(let [content (slurp path)]
|
|
1338
|
+
(println (str "## " path))
|
|
1339
|
+
(println "```markdown")
|
|
1340
|
+
(println content)
|
|
1341
|
+
(println "```")
|
|
1342
|
+
(println))
|
|
1343
|
+
(catch Exception e
|
|
1344
|
+
(println (str "Could not read " path ": " (.getMessage e))))))))
|
|
1345
|
+
|
|
1346
|
+
;; =============================================================================
|
|
1347
|
+
;; Main Entry Point
|
|
1348
|
+
;; =============================================================================
|
|
1349
|
+
|
|
1350
|
+
(def commands
|
|
1351
|
+
{"run" cmd-run
|
|
1352
|
+
"loop" cmd-loop
|
|
1353
|
+
"swarm" cmd-swarm
|
|
1354
|
+
"tasks" cmd-tasks
|
|
1355
|
+
"prompt" cmd-prompt
|
|
1356
|
+
"status" cmd-status
|
|
1357
|
+
"info" cmd-info
|
|
1358
|
+
"list" cmd-list
|
|
1359
|
+
"view" cmd-view
|
|
1360
|
+
"stop" cmd-stop
|
|
1361
|
+
"kill" cmd-kill
|
|
1362
|
+
"worktrees" cmd-worktrees
|
|
1363
|
+
"cleanup" cmd-cleanup
|
|
1364
|
+
"context" cmd-context
|
|
1365
|
+
"check" cmd-check
|
|
1366
|
+
"help" cmd-help
|
|
1367
|
+
"docs" cmd-docs})
|
|
1368
|
+
|
|
1369
|
+
(defn -main [& args]
|
|
1370
|
+
(let [[cmd & rest-args] args]
|
|
1371
|
+
(if-let [handler (get commands cmd)]
|
|
1372
|
+
(try
|
|
1373
|
+
(let [{:keys [opts args]} (parse-args rest-args)]
|
|
1374
|
+
(handler opts args))
|
|
1375
|
+
(catch Exception e
|
|
1376
|
+
(binding [*out* *err*]
|
|
1377
|
+
(println (format "Error: %s" (.getMessage e))))
|
|
1378
|
+
(System/exit 1)))
|
|
1379
|
+
(do
|
|
1380
|
+
(cmd-help {} [])
|
|
1381
|
+
(when cmd
|
|
1382
|
+
(println)
|
|
1383
|
+
(println (format "Unknown command: %s" cmd)))
|
|
1384
|
+
(System/exit (if cmd 1 0))))))
|