@nbardy/oompa 0.6.0 → 0.7.1

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.
@@ -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 codex:4 --iterations 20 # Mixed harnesses
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,8 @@
16
16
  [agentnet.worker :as worker]
17
17
  [agentnet.tasks :as tasks]
18
18
  [agentnet.agent :as agent]
19
+ [agentnet.harness :as harness]
20
+ [agentnet.runs :as runs]
19
21
  [babashka.process :as process]
20
22
  [clojure.string :as str]
21
23
  [clojure.java.io :as io]
@@ -30,20 +32,22 @@
30
32
  (Integer/parseInt s)
31
33
  (catch Exception _ default)))
32
34
 
35
+ (def ^:private harnesses (harness/known-harnesses))
36
+
33
37
  (defn- make-swarm-id
34
38
  "Generate a short run-level swarm ID."
35
39
  []
36
40
  (subs (str (java.util.UUID/randomUUID)) 0 8))
37
41
 
38
42
  (defn- parse-worker-spec
39
- "Parse 'harness:count' into {:harness :claude, :count 5}.
43
+ "Parse 'harness:count' into {:harness :opencode, :count 5}.
40
44
  Throws on invalid format."
41
45
  [s]
42
46
  (let [[harness count-str] (str/split s #":" 2)
43
47
  h (keyword harness)
44
48
  cnt (parse-int count-str 0)]
45
- (when-not (#{:codex :claude} h)
46
- (throw (ex-info (str "Unknown harness in worker spec: " s ". Use 'codex:N' or 'claude:N'") {})))
49
+ (when-not (harnesses h)
50
+ (throw (ex-info (str "Unknown harness in worker spec: " s ". Known: " (str/join ", " (map name (sort harnesses)))) {})))
47
51
  (when (zero? cnt)
48
52
  (throw (ex-info (str "Invalid count in worker spec: " s ". Use format 'harness:count'") {})))
49
53
  {:harness h :count cnt}))
@@ -80,7 +84,7 @@
80
84
  (= arg "--workers")
81
85
  (let [next-arg (second remaining)]
82
86
  (if (worker-spec? next-arg)
83
- ;; Collect all worker specs: --workers claude:5 codex:4
87
+ ;; Collect all worker specs: --workers claude:5 opencode:2
84
88
  (let [[specs rest] (collect-worker-specs (next remaining))]
85
89
  (recur (assoc opts :worker-specs specs) rest))
86
90
  ;; Simple count: --workers 4
@@ -93,8 +97,8 @@
93
97
 
94
98
  (= arg "--harness")
95
99
  (let [h (keyword (second remaining))]
96
- (when-not (#{:codex :claude} h)
97
- (throw (ex-info (str "Unknown harness: " (second remaining) ". Use 'codex' or 'claude'") {})))
100
+ (when-not (harnesses h)
101
+ (throw (ex-info (str "Unknown harness: " (second remaining) ". Known: " (str/join ", " (map name (sort harnesses)))) {})))
98
102
  (recur (assoc opts :harness h)
99
103
  (nnext remaining)))
100
104
 
@@ -135,14 +139,67 @@
135
139
 
136
140
  (declare cmd-swarm parse-model-string)
137
141
 
142
+ (defn- check-git-clean!
143
+ "Abort if git working tree is dirty. Dirty index causes merge conflicts
144
+ and wasted worker iterations."
145
+ []
146
+ (let [result (process/sh ["git" "status" "--porcelain"]
147
+ {:out :string :err :string})
148
+ output (str/trim (:out result))]
149
+ (when (and (zero? (:exit result)) (not (str/blank? output)))
150
+ (println "ERROR: Git working tree is dirty. Resolve before running swarm.")
151
+ (println)
152
+ (println output)
153
+ (println)
154
+ (println "Run 'git stash' or 'git commit' first.")
155
+ (System/exit 1))))
156
+
157
+ (defn- check-stale-worktrees!
158
+ "Abort if stale oompa worktrees or branches exist from a prior run.
159
+ Corrupted .git/worktrees/ entries poison git worktree add for ALL workers,
160
+ not just the worker whose entry is stale. (See swarm af32b180 — kimi-k2.5
161
+ w9 went 20/20 doing nothing because w10's corrupt commondir blocked it.)"
162
+ []
163
+ ;; Prune orphaned metadata first — cleans entries whose directories are gone
164
+ (let [prune-result (process/sh ["git" "worktree" "prune"] {:out :string :err :string})]
165
+ (when-not (zero? (:exit prune-result))
166
+ (println "WARNING: git worktree prune failed:")
167
+ (println (:err prune-result))))
168
+ (let [;; Find .ww* directories (oompa per-iteration worktree naming convention)
169
+ ls-result (process/sh ["find" "." "-maxdepth" "1" "-type" "d" "-name" ".ww*"]
170
+ {:out :string})
171
+ stale-dirs (when (zero? (:exit ls-result))
172
+ (->> (str/split-lines (:out ls-result))
173
+ (remove str/blank?)))
174
+ ;; Find oompa/* branches
175
+ br-result (process/sh ["git" "branch" "--list" "oompa/*"]
176
+ {:out :string})
177
+ stale-branches (when (zero? (:exit br-result))
178
+ (->> (str/split-lines (:out br-result))
179
+ (map str/trim)
180
+ (remove str/blank?)))]
181
+ (when (or (seq stale-dirs) (seq stale-branches))
182
+ (println "ERROR: Stale oompa worktrees detected from a prior run.")
183
+ (println " Corrupt worktree metadata will cause worker failures.")
184
+ (println)
185
+ (when (seq stale-dirs)
186
+ (println (format " Stale directories (%d):" (count stale-dirs)))
187
+ (doseq [d stale-dirs] (println (str " " d))))
188
+ (when (seq stale-branches)
189
+ (println (format " Stale branches (%d):" (count stale-branches)))
190
+ (doseq [b stale-branches] (println (str " " b))))
191
+ (println)
192
+ (println "Clean up with:")
193
+ (println " git worktree prune; for d in .ww*/; do git worktree remove --force \"$d\" 2>/dev/null; done; git branch --list 'oompa/*' | xargs git branch -D 2>/dev/null; rm -rf .ww*")
194
+ (println)
195
+ (System/exit 1))))
196
+
138
197
  (defn- probe-model
139
198
  "Send 'say ok' to a model via its harness CLI. Returns true if model responds.
140
- Claude hangs without /dev/null stdin when spawned from bb."
141
- [harness model]
199
+ Uses harness/build-probe-cmd for the command, /dev/null stdin to prevent hang."
200
+ [harness-kw model]
142
201
  (try
143
- (let [cmd (case harness
144
- :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"])
202
+ (let [cmd (harness/build-probe-cmd harness-kw model)
146
203
  null-in (io/input-stream (io/file "/dev/null"))
147
204
  proc (process/process cmd {:out :string :err :string :in null-in})
148
205
  result (deref proc 30000 :timeout)]
@@ -187,7 +244,7 @@
187
244
  (cmd-swarm opts (or (seq args) ["oompa.json"]))
188
245
  (let [swarm-id (make-swarm-id)]
189
246
  (if-let [specs (:worker-specs opts)]
190
- ;; Mixed worker specs: --workers claude:5 codex:4
247
+ ;; Mixed worker specs: --workers claude:5 opencode:2
191
248
  (let [workers (mapcat
192
249
  (fn [spec]
193
250
  (let [{:keys [harness count]} spec]
@@ -206,10 +263,10 @@
206
263
  (println (format " %dx %s" (:count spec) (name (:harness spec)))))
207
264
  (println)
208
265
  (worker/run-workers! workers))
209
- ;; Simple mode
266
+ ;; Simple mode retired — use oompa.json or --workers harness:count
210
267
  (do
211
- (println (format "Swarm ID: %s" swarm-id))
212
- (orchestrator/run-once! (assoc opts :swarm-id swarm-id)))))))
268
+ (println "Simple mode is no longer supported. Use oompa.json or --workers harness:count.")
269
+ (System/exit 1))))))
213
270
 
214
271
  (defn cmd-loop
215
272
  "Run orchestrator N times"
@@ -219,7 +276,7 @@
219
276
  (:iterations opts)
220
277
  20)]
221
278
  (if-let [specs (:worker-specs opts)]
222
- ;; Mixed worker specs: --workers claude:5 codex:4
279
+ ;; Mixed worker specs: --workers claude:5 opencode:2
223
280
  (let [workers (mapcat
224
281
  (fn [spec]
225
282
  (let [{:keys [harness count]} spec]
@@ -238,14 +295,10 @@
238
295
  (println (format " %dx %s" (:count spec) (name (:harness spec)))))
239
296
  (println)
240
297
  (worker/run-workers! workers))
241
- ;; Simple mode: --workers N --harness X
242
- (let [model-str (if (:model opts)
243
- (format " (model: %s)" (:model opts))
244
- "")]
245
- (println (format "Starting %d iterations with %s harness%s..."
246
- iterations (name (:harness opts)) model-str))
247
- (println (format "Swarm ID: %s" swarm-id))
248
- (orchestrator/run-loop! iterations (assoc opts :swarm-id swarm-id))))))
298
+ ;; Simple mode retired — use oompa.json or --workers harness:count
299
+ (do
300
+ (println "Simple mode is no longer supported. Use oompa.json or --workers harness:count.")
301
+ (System/exit 1)))))
249
302
 
250
303
  (defn cmd-prompt
251
304
  "Run ad-hoc prompt as single task"
@@ -265,25 +318,64 @@
265
318
  (orchestrator/run-once! opts))))
266
319
 
267
320
  (defn cmd-status
268
- "Show status of last run"
321
+ "Show status of last run — reads event-sourced runs/{swarm-id}/ data."
269
322
  [opts args]
270
- (let [runs-dir (io/file "runs")
271
- files (when (.exists runs-dir)
272
- (->> (.listFiles runs-dir)
273
- (filter #(.isFile %))
274
- (sort-by #(.lastModified %) >)))]
275
- (if-let [latest (first files)]
276
- (do
277
- (println (format "Latest run: %s" (.getName latest)))
323
+ (let [run-ids (runs/list-runs)]
324
+ (if (seq run-ids)
325
+ (let [swarm-id (or (first args) (first run-ids))
326
+ started (runs/read-started swarm-id)
327
+ stopped (runs/read-stopped swarm-id)
328
+ cycles (runs/list-cycles swarm-id)
329
+ reviews (runs/list-reviews swarm-id)]
330
+ (println (format "Swarm: %s" swarm-id))
331
+ (when started
332
+ (println (format " Started: %s" (:started-at started)))
333
+ (println (format " PID: %s" (or (:pid started) "N/A")))
334
+ (println (format " Config: %s" (or (:config-file started) "N/A")))
335
+ (println (format " Workers: %d" (count (:workers started)))))
278
336
  (println)
279
- (with-open [r (io/reader latest)]
280
- (let [entries (mapv #(json/parse-string % true) (line-seq r))
281
- by-status (group-by :status entries)]
282
- (doseq [[status tasks] (sort-by first by-status)]
283
- (println (format "%s: %d" (name status) (count tasks))))
337
+ (if stopped
338
+ (println (format "Stopped: %s (reason: %s%s)"
339
+ (:stopped-at stopped)
340
+ (:reason stopped)
341
+ (if (:error stopped)
342
+ (str ", error: " (:error stopped))
343
+ "")))
344
+ (println " (still running — no stopped event yet)"))
345
+ (when (seq cycles)
346
+ (println)
347
+ (println (format "Cycles: %d total" (count cycles)))
348
+ (doseq [c cycles]
349
+ (println (format " %s-c%d: %s (%dms, claimed: %s)"
350
+ (:worker-id c) (:cycle c)
351
+ (:outcome c)
352
+ (or (:duration-ms c) 0)
353
+ (str/join ", " (or (:claimed-task-ids c) []))))))
354
+ (when (seq reviews)
355
+ (println)
356
+ (println (format "Reviews: %d total" (count reviews)))
357
+ (doseq [r reviews]
358
+ (println (format " %s-c%d-r%d: %s"
359
+ (:worker-id r) (:cycle r) (:round r)
360
+ (:verdict r))))))
361
+ ;; Fall back to legacy JSONL format
362
+ (let [runs-dir (io/file "runs")
363
+ files (when (.exists runs-dir)
364
+ (->> (.listFiles runs-dir)
365
+ (filter #(.isFile %))
366
+ (sort-by #(.lastModified %) >)))]
367
+ (if-let [latest (first files)]
368
+ (do
369
+ (println (format "Latest run (legacy): %s" (.getName latest)))
284
370
  (println)
285
- (println (format "Total: %d tasks" (count entries))))))
286
- (println "No runs found."))))
371
+ (with-open [r (io/reader latest)]
372
+ (let [entries (mapv #(json/parse-string % true) (line-seq r))
373
+ by-status (group-by :status entries)]
374
+ (doseq [[status tasks] (sort-by first by-status)]
375
+ (println (format "%s: %d" (name status) (count tasks))))
376
+ (println)
377
+ (println (format "Total: %d tasks" (count entries))))))
378
+ (println "No runs found."))))))
287
379
 
288
380
  (defn cmd-worktrees
289
381
  "List worktree status"
@@ -323,21 +415,43 @@
323
415
  "Check if agent backends are available"
324
416
  [opts args]
325
417
  (println "Checking agent backends...")
326
- (doseq [agent-type [:codex :claude]]
327
- (let [available? (agent/check-available agent-type)]
418
+ (doseq [harness-kw (sort (harness/known-harnesses))]
419
+ (let [available? (harness/check-available harness-kw)]
328
420
  (println (format " %s: %s"
329
- (name agent-type)
421
+ (name harness-kw)
330
422
  (if available? "✓ available" "✗ not found"))))))
331
423
 
424
+ (def ^:private reasoning-variants
425
+ #{"minimal" "low" "medium" "high" "max" "xhigh"})
426
+
332
427
  (defn- parse-model-string
333
428
  "Parse model string into {:harness :model :reasoning}.
334
- Formats: 'harness:model', 'harness:model:reasoning', or just 'model'."
429
+
430
+ Supported formats:
431
+ - harness:model
432
+ - harness:model:reasoning (codex only)
433
+ - model (defaults harness to :codex)
434
+
435
+ Note: non-codex model identifiers may contain ':' (for example
436
+ openrouter/...:free). Those suffixes are preserved in :model."
335
437
  [s]
336
438
  (if (and s (str/includes? s ":"))
337
- (let [parts (str/split s #":" 3)]
338
- (case (count parts)
339
- 2 {:harness (keyword (first parts)) :model (second parts)}
340
- 3 {:harness (keyword (first parts)) :model (second parts) :reasoning (nth parts 2)}
439
+ (let [[harness-str rest*] (str/split s #":" 2)
440
+ harness (keyword harness-str)]
441
+ (if (contains? harnesses harness)
442
+ (if (= harness :codex)
443
+ ;; Codex may include a reasoning suffix at the end. Only treat the
444
+ ;; last segment as reasoning if it matches a known variant.
445
+ (if-let [idx (str/last-index-of rest* ":")]
446
+ (let [model* (subs rest* 0 idx)
447
+ reasoning* (subs rest* (inc idx))]
448
+ (if (contains? reasoning-variants reasoning*)
449
+ {:harness harness :model model* :reasoning reasoning*}
450
+ {:harness harness :model rest*}))
451
+ {:harness harness :model rest*})
452
+ ;; Non-codex: preserve full model string (including any ':suffix').
453
+ {:harness harness :model rest*})
454
+ ;; Not a known harness prefix, treat as raw model on default harness.
341
455
  {:harness :codex :model s}))
342
456
  {:harness :codex :model s}))
343
457
 
@@ -354,14 +468,50 @@
354
468
  (println "{")
355
469
  (println " \"workers\": [")
356
470
  (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}")
471
+ (println " {\"model\": \"claude:opus\", \"prompt\": [\"prompts/base.md\", \"prompts/planner.md\"], \"count\": 1},")
472
+ (println " {\"model\": \"gemini:gemini-3-pro-preview\", \"prompt\": [\"prompts/executor.md\"], \"count\": 1}")
358
473
  (println " ]")
359
474
  (println "}")
360
475
  (println)
361
476
  (println "prompt: string or array of paths — concatenated into one prompt.")
362
477
  (System/exit 1))
478
+ ;; Preflight: abort if git is dirty to prevent merge conflicts
479
+ (check-git-clean!)
480
+ ;; Preflight: abort if stale worktrees from prior runs would poison git
481
+ (check-stale-worktrees!)
482
+
363
483
  (let [config (json/parse-string (slurp f) true)
364
- review-model (some-> (:review_model config) parse-model-string)
484
+ ;; Parse reviewer config — supports both formats:
485
+ ;; Legacy: {"review_model": "harness:model:reasoning"}
486
+ ;; New: {"reviewer": {"model": "harness:model:reasoning", "prompt": ["path.md"]}}
487
+ reviewer-config (:reviewer config)
488
+ review-parsed (cond
489
+ reviewer-config
490
+ (let [parsed (parse-model-string (:model reviewer-config))
491
+ prompts (let [p (:prompt reviewer-config)]
492
+ (cond (vector? p) p
493
+ (string? p) [p]
494
+ :else []))]
495
+ (assoc parsed :prompts prompts))
496
+
497
+ (:review_model config)
498
+ (parse-model-string (:review_model config))
499
+
500
+ :else nil)
501
+
502
+ ;; Parse planner config — optional dedicated planner
503
+ ;; Runs in project root, no worktree/review/merge, respects max_pending backpressure
504
+ planner-config (:planner config)
505
+ planner-parsed (when planner-config
506
+ (let [parsed (parse-model-string (:model planner-config))
507
+ prompts (let [p (:prompt planner-config)]
508
+ (cond (vector? p) p
509
+ (string? p) [p]
510
+ :else []))]
511
+ (assoc parsed
512
+ :prompts prompts
513
+ :max-pending (or (:max_pending planner-config) 10))))
514
+
365
515
  worker-configs (:workers config)
366
516
 
367
517
  ;; Expand worker configs by count
@@ -383,14 +533,30 @@
383
533
  :iterations (or (:iterations wc) 10)
384
534
  :prompts (:prompt wc)
385
535
  :can-plan (:can_plan wc)
386
- :review-harness (:harness review-model)
387
- :review-model (:model review-model)})))
536
+ :wait-between (:wait_between wc)
537
+ :max-working-resumes (:max_working_resumes wc)
538
+ :review-harness (:harness review-parsed)
539
+ :review-model (:model review-parsed)
540
+ :review-prompts (:prompts review-parsed)})))
388
541
  expanded-workers)]
389
542
 
390
543
  (println (format "Swarm config from %s:" config-file))
391
544
  (println (format " Swarm ID: %s" swarm-id))
392
- (when review-model
393
- (println (format " Review: %s:%s" (name (:harness review-model)) (:model review-model))))
545
+ (when planner-parsed
546
+ (println (format " Planner: %s:%s (max_pending: %d%s)"
547
+ (name (:harness planner-parsed))
548
+ (:model planner-parsed)
549
+ (:max-pending planner-parsed)
550
+ (if (seq (:prompts planner-parsed))
551
+ (str ", prompts: " (str/join ", " (:prompts planner-parsed)))
552
+ ""))))
553
+ (when review-parsed
554
+ (println (format " Reviewer: %s:%s%s"
555
+ (name (:harness review-parsed))
556
+ (:model review-parsed)
557
+ (if (seq (:prompts review-parsed))
558
+ (str " (prompts: " (str/join ", " (:prompts review-parsed)) ")")
559
+ ""))))
394
560
  (println (format " Workers: %d total" (count workers)))
395
561
  (doseq [[idx wc] (map-indexed vector worker-configs)]
396
562
  (let [{:keys [harness model reasoning]} (parse-model-string (:model wc))]
@@ -404,7 +570,27 @@
404
570
  (println)
405
571
 
406
572
  ;; Preflight: probe each unique model before launching workers
407
- (validate-models! worker-configs review-model)
573
+ ;; Include planner model in validation if configured
574
+ (validate-models! (cond-> worker-configs
575
+ planner-config (conj planner-config))
576
+ review-parsed)
577
+
578
+ ;; Write started event to runs/{swarm-id}/started.json
579
+ (runs/write-started! swarm-id
580
+ {:workers workers
581
+ :planner-config planner-parsed
582
+ :reviewer-config review-parsed
583
+ :config-file config-file})
584
+ (println (format "\nStarted event written to runs/%s/started.json" swarm-id))
585
+
586
+ ;; Run planner if configured — synchronously before workers
587
+ (when planner-parsed
588
+ (println)
589
+ (println (format " Planner: %s:%s (max_pending: %d)"
590
+ (name (:harness planner-parsed))
591
+ (:model planner-parsed)
592
+ (:max-pending planner-parsed)))
593
+ (worker/run-planner! (assoc planner-parsed :swarm-id swarm-id)))
408
594
 
409
595
  ;; Run workers using new worker module
410
596
  (worker/run-workers! workers))))
@@ -428,6 +614,69 @@
428
614
  (doseq [t (tasks/list-current)]
429
615
  (println (format " - %s: %s" (:id t) (:summary t)))))))
430
616
 
617
+ (defn- find-latest-swarm-id
618
+ "Find the most recent swarm ID from runs/ directory."
619
+ []
620
+ (first (runs/list-runs)))
621
+
622
+ (defn- read-swarm-pid
623
+ "Read PID from started.json for a swarm. Returns nil if not found."
624
+ [swarm-id]
625
+ (when-let [started (runs/read-started swarm-id)]
626
+ (:pid started)))
627
+
628
+ (defn- pid-alive?
629
+ "Check if a process is alive via kill -0."
630
+ [pid]
631
+ (try
632
+ (zero? (:exit (process/sh ["kill" "-0" (str pid)]
633
+ {:out :string :err :string})))
634
+ (catch Exception _ false)))
635
+
636
+ (defn cmd-stop
637
+ "Send SIGTERM to running swarm — workers finish current cycle then exit"
638
+ [opts args]
639
+ (let [swarm-id (or (first args) (find-latest-swarm-id))]
640
+ (if-not swarm-id
641
+ (println "No swarm runs found.")
642
+ (let [stopped (runs/read-stopped swarm-id)]
643
+ (if stopped
644
+ (println (format "Swarm %s already stopped (reason: %s)" swarm-id (:reason stopped)))
645
+ (let [pid (read-swarm-pid swarm-id)]
646
+ (if-not pid
647
+ (println (format "No PID found for swarm %s" swarm-id))
648
+ (if-not (pid-alive? pid)
649
+ (do
650
+ (println (format "Swarm %s PID %s is not running (stale). Writing stopped event." swarm-id pid))
651
+ (runs/write-stopped! swarm-id :interrupted))
652
+ (do
653
+ (println (format "Sending SIGTERM to swarm %s (PID %s)..." swarm-id pid))
654
+ (println "Workers will finish their current cycle and exit.")
655
+ (process/sh ["kill" (str pid)]))))))))))
656
+
657
+ (defn cmd-kill
658
+ "Send SIGKILL to running swarm — immediate termination"
659
+ [opts args]
660
+ (let [swarm-id (or (first args) (find-latest-swarm-id))]
661
+ (if-not swarm-id
662
+ (println "No swarm runs found.")
663
+ (let [stopped (runs/read-stopped swarm-id)]
664
+ (if stopped
665
+ (println (format "Swarm %s already stopped (reason: %s)" swarm-id (:reason stopped)))
666
+ (let [pid (read-swarm-pid swarm-id)]
667
+ (if-not pid
668
+ (println (format "No PID found for swarm %s" swarm-id))
669
+ (if-not (pid-alive? pid)
670
+ (do
671
+ (println (format "Swarm %s PID %s is not running (stale). Writing stopped event." swarm-id pid))
672
+ (runs/write-stopped! swarm-id :interrupted))
673
+ (do
674
+ (println (format "Sending SIGKILL to swarm %s (PID %s)..." swarm-id pid))
675
+ ;; SIGKILL bypasses JVM shutdown hooks, so write stopped.json here
676
+ (process/sh ["kill" "-9" (str pid)])
677
+ (runs/write-stopped! swarm-id :interrupted)
678
+ (println "Swarm killed."))))))))))
679
+
431
680
  (defn cmd-help
432
681
  "Print usage information"
433
682
  [opts args]
@@ -443,6 +692,8 @@
443
692
  (println " prompt \"...\" Run ad-hoc prompt")
444
693
  (println " status Show last run summary")
445
694
  (println " worktrees List worktree status")
695
+ (println " stop [swarm-id] Stop swarm gracefully (finish current cycle)")
696
+ (println " kill [swarm-id] Kill swarm immediately (SIGKILL)")
446
697
  (println " cleanup Remove all worktrees")
447
698
  (println " context Print context block")
448
699
  (println " check Check agent backends")
@@ -450,16 +701,16 @@
450
701
  (println)
451
702
  (println "Options:")
452
703
  (println " --workers N Number of parallel workers (default: 2)")
453
- (println " --workers H:N [H:N ...] Mixed workers by harness (e.g., claude:5 codex:4)")
704
+ (println " --workers H:N [H:N ...] Mixed workers by harness (e.g., claude:5 opencode:2)")
454
705
  (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)")
706
+ (println (str " --harness {" (str/join "," (map name (sort harnesses))) "} Agent harness to use (default: codex)"))
707
+ (println " --model MODEL Model to use (e.g., codex:gpt-5.3-codex:medium, claude:opus, gemini:gemini-3-pro-preview)")
457
708
  (println " --dry-run Skip actual merges")
458
709
  (println " --keep-worktrees Don't cleanup worktrees after run")
459
710
  (println)
460
711
  (println "Examples:")
461
712
  (println " ./swarm.bb loop 10 --harness codex --model gpt-5.3-codex --workers 3")
462
- (println " ./swarm.bb loop --workers claude:5 codex:4 --iterations 20")
713
+ (println " ./swarm.bb loop --workers claude:5 opencode:2 --iterations 20")
463
714
  (println " ./swarm.bb swarm oompa.json # Run multi-model config"))
464
715
 
465
716
  ;; =============================================================================
@@ -473,6 +724,8 @@
473
724
  "tasks" cmd-tasks
474
725
  "prompt" cmd-prompt
475
726
  "status" cmd-status
727
+ "stop" cmd-stop
728
+ "kill" cmd-kill
476
729
  "worktrees" cmd-worktrees
477
730
  "cleanup" cmd-cleanup
478
731
  "context" cmd-context