@nbardy/oompa 0.7.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.
- package/README.md +21 -4
- package/agentnet/src/agentnet/agent.clj +125 -6
- package/agentnet/src/agentnet/cli.clj +189 -63
- package/agentnet/src/agentnet/harness.clj +217 -0
- package/agentnet/src/agentnet/orchestrator.clj +2 -0
- package/agentnet/src/agentnet/runs.clj +73 -48
- package/agentnet/src/agentnet/schema.clj +1 -1
- package/agentnet/src/agentnet/tasks.clj +47 -0
- package/agentnet/src/agentnet/worker.clj +580 -305
- package/bin/test-models +1 -1
- package/config/prompts/_agent_scope_rules.md +7 -0
- package/config/prompts/_task_header.md +16 -48
- package/config/prompts/cto.md +2 -0
- package/config/prompts/engineer.md +2 -0
- package/config/prompts/executor.md +2 -0
- package/config/prompts/planner.md +3 -1
- package/config/prompts/reviewer.md +2 -0
- package/config/prompts/worker.md +7 -4
- package/oompa.example.json +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,7 +85,7 @@ This repo has a fleshed out version of the idea. The oompa loompas are organized
|
|
|
85
85
|
"workers": [
|
|
86
86
|
{"model": "claude:opus", "prompt": ["config/prompts/planner.md"], "iterations": 5, "count": 1},
|
|
87
87
|
{"model": "codex:gpt-5.3-codex:medium", "prompt": ["config/prompts/executor.md"], "iterations": 10, "count": 2, "can_plan": false},
|
|
88
|
-
{"model": "opencode:
|
|
88
|
+
{"model": "opencode:opencode/kimi-k2.5-free", "prompt": ["config/prompts/executor.md"], "iterations": 10, "count": 1, "can_plan": false}
|
|
89
89
|
]
|
|
90
90
|
}
|
|
91
91
|
```
|
|
@@ -93,13 +93,13 @@ This repo has a fleshed out version of the idea. The oompa loompas are organized
|
|
|
93
93
|
This spawns:
|
|
94
94
|
- **1 planner** (opus) — reads spec, explores codebase, creates/refines tasks
|
|
95
95
|
- **2 codex executors** (gpt-5.3-codex, medium reasoning) — claims and executes tasks fast
|
|
96
|
-
- **1 opencode executor** (
|
|
96
|
+
- **1 opencode executor** (opencode/kimi-k2.5-free) — same task loop via `opencode run`
|
|
97
97
|
|
|
98
98
|
#### Worker fields
|
|
99
99
|
|
|
100
100
|
| Field | Required | Description |
|
|
101
101
|
|-------|----------|-------------|
|
|
102
|
-
| `model` | yes | `harness:model` or `harness:model:reasoning` (e.g. `codex:gpt-5.3-codex:medium`, `claude:opus`, `opencode:
|
|
102
|
+
| `model` | yes | `harness:model` or `harness:model:reasoning` (e.g. `codex:gpt-5.3-codex:medium`, `claude:opus`, `opencode:opencode/kimi-k2.5-free`) |
|
|
103
103
|
| `prompt` | no | String or array of paths — concatenated into one prompt |
|
|
104
104
|
| `iterations` | no | Max iterations per worker (default: 10) |
|
|
105
105
|
| `count` | no | Number of workers with this config (default: 1) |
|
|
@@ -113,7 +113,7 @@ This spawns:
|
|
|
113
113
|
{
|
|
114
114
|
"workers": [
|
|
115
115
|
{"model": "claude:opus-4.5", "prompt": ["prompts/base.md", "prompts/architect.md"], "count": 1},
|
|
116
|
-
{"model": "opencode:
|
|
116
|
+
{"model": "opencode:opencode/kimi-k2.5-free", "prompt": ["prompts/base.md", "prompts/frontend.md"], "count": 2},
|
|
117
117
|
{"model": "codex:codex-5.2-mini", "prompt": ["prompts/base.md", "prompts/backend.md"], "count": 2}
|
|
118
118
|
]
|
|
119
119
|
}
|
|
@@ -121,6 +121,23 @@ This spawns:
|
|
|
121
121
|
|
|
122
122
|
Every worker automatically gets task management instructions injected above your prompts. Your prompts just say *what* the worker should do — the framework handles *how* tasks work.
|
|
123
123
|
|
|
124
|
+
#### Prompt includes
|
|
125
|
+
|
|
126
|
+
Prompts support `#oompa_directive:include_file "path/to/file.md"` lines.
|
|
127
|
+
|
|
128
|
+
Use it to share common instructions across roles without copying content.
|
|
129
|
+
Paths are resolved relative to the prompt file containing the directive.
|
|
130
|
+
|
|
131
|
+
Example:
|
|
132
|
+
|
|
133
|
+
```md
|
|
134
|
+
#oompa_directive:include_file "config/prompts/_agent_scope_rules.md"
|
|
135
|
+
|
|
136
|
+
You are an executor. Focus on minimal changes and complete tasks.
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The included file is inlined during prompt load, with a short header noting the injected source.
|
|
140
|
+
|
|
124
141
|
### Task System
|
|
125
142
|
|
|
126
143
|
Workers self-organize via the filesystem. Tasks live at the project root and are shared across all worktrees:
|
|
@@ -88,6 +88,12 @@
|
|
|
88
88
|
attach (into ["--attach" attach])
|
|
89
89
|
true (conj prompt))))
|
|
90
90
|
|
|
91
|
+
(defmethod build-command :gemini
|
|
92
|
+
[_ {:keys [model]} prompt cwd]
|
|
93
|
+
(cond-> ["gemini" "--yolo"]
|
|
94
|
+
model (into ["-m" model])
|
|
95
|
+
true (into ["-p" prompt])))
|
|
96
|
+
|
|
91
97
|
(defmethod build-command :default
|
|
92
98
|
[agent-type _ _ _]
|
|
93
99
|
(throw (ex-info (str "Unknown agent type: " agent-type)
|
|
@@ -125,7 +131,102 @@
|
|
|
125
131
|
:stdout (truncate (:out result) 10000)
|
|
126
132
|
:stderr (truncate (:err result) 5000)
|
|
127
133
|
:duration-ms (- (now-ms) start)
|
|
128
|
-
|
|
134
|
+
:timed-out? (boolean (:timed-out result))}))
|
|
135
|
+
|
|
136
|
+
;; =============================================================================
|
|
137
|
+
;; Prompt Loading Helpers
|
|
138
|
+
;; =============================================================================
|
|
139
|
+
|
|
140
|
+
(defn- file-canonical-path
|
|
141
|
+
"Resolve a path for cache keys and cycle detection."
|
|
142
|
+
[path]
|
|
143
|
+
(try
|
|
144
|
+
(.getCanonicalPath (io/file path))
|
|
145
|
+
(catch Exception _
|
|
146
|
+
path)))
|
|
147
|
+
|
|
148
|
+
(def ^:private prompt-file-cache
|
|
149
|
+
"Cache for prompt include expansion."
|
|
150
|
+
(atom {}))
|
|
151
|
+
|
|
152
|
+
(def ^:private include-directive-pattern
|
|
153
|
+
#"(?m)^\s*#oompa_directive:include_file\s+\"([^\"]+)\"\s*$")
|
|
154
|
+
|
|
155
|
+
(defn- read-file-cached
|
|
156
|
+
"Read a prompt file once and cache by canonical path."
|
|
157
|
+
[path]
|
|
158
|
+
(when path
|
|
159
|
+
(if-let [cached (get @prompt-file-cache path)]
|
|
160
|
+
cached
|
|
161
|
+
(let [f (io/file path)]
|
|
162
|
+
(when (.exists f)
|
|
163
|
+
(let [content (slurp f)]
|
|
164
|
+
(swap! prompt-file-cache assoc path content)
|
|
165
|
+
content))))))
|
|
166
|
+
|
|
167
|
+
(defn- resolve-include-path
|
|
168
|
+
"Resolve an include path relative to the file that declares it."
|
|
169
|
+
[source-path include-path]
|
|
170
|
+
(let [source-file (io/file source-path)
|
|
171
|
+
base-dir (.getParentFile source-file)]
|
|
172
|
+
(if (or (str/starts-with? include-path "/")
|
|
173
|
+
(and (> (count include-path) 1)
|
|
174
|
+
(= (nth include-path 1) \:)) ; Windows drive letter
|
|
175
|
+
(str/starts-with? include-path "~"))
|
|
176
|
+
include-path
|
|
177
|
+
(if base-dir
|
|
178
|
+
(str (io/file base-dir include-path))
|
|
179
|
+
include-path))))
|
|
180
|
+
|
|
181
|
+
(defn- expand-includes
|
|
182
|
+
"Expand #oompa_directive:include_file directives recursively.
|
|
183
|
+
|
|
184
|
+
Directive syntax:
|
|
185
|
+
#oompa_directive:include_file \"relative/or/absolute/path.md\"
|
|
186
|
+
|
|
187
|
+
Includes are resolved relative to the prompt file containing the directive.
|
|
188
|
+
Cycles are guarded by a simple visited-set."
|
|
189
|
+
([raw source-path]
|
|
190
|
+
(expand-includes raw source-path #{}))
|
|
191
|
+
([raw source-path visited]
|
|
192
|
+
(let [source-canonical (file-canonical-path source-path)
|
|
193
|
+
lines (str/split-lines (or raw ""))
|
|
194
|
+
visited' (conj visited source-canonical)]
|
|
195
|
+
(str/join
|
|
196
|
+
"\n"
|
|
197
|
+
(mapcat
|
|
198
|
+
(fn [line]
|
|
199
|
+
(if-let [match (re-matches include-directive-pattern line)]
|
|
200
|
+
(let [include-target (second match)
|
|
201
|
+
include-path (resolve-include-path source-canonical include-target)
|
|
202
|
+
include-canonical (file-canonical-path include-path)
|
|
203
|
+
included (and (not (str/blank? include-path))
|
|
204
|
+
(read-file-cached include-canonical))]
|
|
205
|
+
(cond
|
|
206
|
+
(str/blank? include-target)
|
|
207
|
+
["[oompa] Empty include target in prompt directive"]
|
|
208
|
+
|
|
209
|
+
(contains? visited' include-canonical)
|
|
210
|
+
[(format "[oompa] Skipping already-included file: \"%s\"" include-target)]
|
|
211
|
+
|
|
212
|
+
(not included)
|
|
213
|
+
[(format "[oompa] Could not include \"%s\"" include-target)]
|
|
214
|
+
|
|
215
|
+
:else
|
|
216
|
+
(cons (format "We have included the content of file: \"%s\" below"
|
|
217
|
+
include-target)
|
|
218
|
+
(str/split-lines
|
|
219
|
+
(expand-includes included include-canonical visited')))))
|
|
220
|
+
[line]))
|
|
221
|
+
lines)))))
|
|
222
|
+
|
|
223
|
+
(defn- load-prompt-file
|
|
224
|
+
"Load a prompt file and expand include directives."
|
|
225
|
+
[path]
|
|
226
|
+
(when path
|
|
227
|
+
(when-let [f (io/file path)]
|
|
228
|
+
(when (.exists f)
|
|
229
|
+
(expand-includes (slurp f) (file-canonical-path path))))))
|
|
129
230
|
|
|
130
231
|
;; =============================================================================
|
|
131
232
|
;; Output Parsing
|
|
@@ -152,6 +253,17 @@
|
|
|
152
253
|
[output]
|
|
153
254
|
(boolean (re-find #"COMPLETE_AND_READY_FOR_MERGE" (or output ""))))
|
|
154
255
|
|
|
256
|
+
(defn parse-claim-signal
|
|
257
|
+
"Extract task IDs from CLAIM(...) signal in output.
|
|
258
|
+
Returns vector of task ID strings, or nil if no CLAIM signal found.
|
|
259
|
+
Format: CLAIM(task-001, task-003, task-005)"
|
|
260
|
+
[output]
|
|
261
|
+
(when-let [match (re-find #"CLAIM\(([^)]+)\)" (or output ""))]
|
|
262
|
+
(->> (str/split (second match) #",")
|
|
263
|
+
(map str/trim)
|
|
264
|
+
(remove str/blank?)
|
|
265
|
+
vec)))
|
|
266
|
+
|
|
155
267
|
(defn- extract-comments
|
|
156
268
|
"Extract bullet-point comments from output"
|
|
157
269
|
[output]
|
|
@@ -228,7 +340,7 @@
|
|
|
228
340
|
(let [filename (str "config/prompts/" (name role) ".md")
|
|
229
341
|
f (io/file filename)]
|
|
230
342
|
(when (.exists f)
|
|
231
|
-
(
|
|
343
|
+
(load-prompt-file filename))))
|
|
232
344
|
|
|
233
345
|
(defn load-custom-prompt
|
|
234
346
|
"Load a custom prompt file. Returns content or nil."
|
|
@@ -236,10 +348,11 @@
|
|
|
236
348
|
(when path
|
|
237
349
|
(let [f (io/file path)]
|
|
238
350
|
(when (.exists f)
|
|
239
|
-
(
|
|
351
|
+
(load-prompt-file path)))))
|
|
240
352
|
|
|
241
|
-
(defn
|
|
242
|
-
"Replace {tokens} in template with values from context map
|
|
353
|
+
(defn tokenize
|
|
354
|
+
"Replace {tokens} in template with values from context map.
|
|
355
|
+
Keys can be keywords or strings; values are stringified."
|
|
243
356
|
[template tokens]
|
|
244
357
|
(reduce (fn [acc [k v]]
|
|
245
358
|
(str/replace acc
|
|
@@ -287,12 +400,18 @@
|
|
|
287
400
|
:codex ["codex" "--version"]
|
|
288
401
|
:claude ["claude" "--version"]
|
|
289
402
|
:opencode ["opencode" "--version"]
|
|
403
|
+
:gemini ["gemini" "--version"]
|
|
290
404
|
["echo" "unknown"])]
|
|
291
405
|
(try
|
|
292
406
|
(let [{:keys [exit]} (process/sh cmd {:out :string :err :string})]
|
|
293
407
|
(zero? exit))
|
|
294
408
|
(catch Exception _
|
|
295
|
-
|
|
409
|
+
;; Some CLIs (like gemini) may error on --version due to config issues
|
|
410
|
+
;; but still exist on PATH. Fall back to `which`.
|
|
411
|
+
(try
|
|
412
|
+
(let [{:keys [exit]} (process/sh ["which" (first cmd)] {:out :string :err :string})]
|
|
413
|
+
(zero? exit))
|
|
414
|
+
(catch Exception _ false))))))
|
|
296
415
|
|
|
297
416
|
(defn select-backend
|
|
298
417
|
"Select first available backend from preference list"
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
[agentnet.worker :as worker]
|
|
17
17
|
[agentnet.tasks :as tasks]
|
|
18
18
|
[agentnet.agent :as agent]
|
|
19
|
+
[agentnet.harness :as harness]
|
|
19
20
|
[agentnet.runs :as runs]
|
|
20
21
|
[babashka.process :as process]
|
|
21
22
|
[clojure.string :as str]
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
(Integer/parseInt s)
|
|
32
33
|
(catch Exception _ default)))
|
|
33
34
|
|
|
34
|
-
(def ^:private harnesses
|
|
35
|
+
(def ^:private harnesses (harness/known-harnesses))
|
|
35
36
|
|
|
36
37
|
(defn- make-swarm-id
|
|
37
38
|
"Generate a short run-level swarm ID."
|
|
@@ -46,7 +47,7 @@
|
|
|
46
47
|
h (keyword harness)
|
|
47
48
|
cnt (parse-int count-str 0)]
|
|
48
49
|
(when-not (harnesses h)
|
|
49
|
-
(throw (ex-info (str "Unknown harness in worker spec: " s ".
|
|
50
|
+
(throw (ex-info (str "Unknown harness in worker spec: " s ". Known: " (str/join ", " (map name (sort harnesses)))) {})))
|
|
50
51
|
(when (zero? cnt)
|
|
51
52
|
(throw (ex-info (str "Invalid count in worker spec: " s ". Use format 'harness:count'") {})))
|
|
52
53
|
{:harness h :count cnt}))
|
|
@@ -97,7 +98,7 @@
|
|
|
97
98
|
(= arg "--harness")
|
|
98
99
|
(let [h (keyword (second remaining))]
|
|
99
100
|
(when-not (harnesses h)
|
|
100
|
-
(throw (ex-info (str "Unknown harness: " (second remaining) ".
|
|
101
|
+
(throw (ex-info (str "Unknown harness: " (second remaining) ". Known: " (str/join ", " (map name (sort harnesses)))) {})))
|
|
101
102
|
(recur (assoc opts :harness h)
|
|
102
103
|
(nnext remaining)))
|
|
103
104
|
|
|
@@ -153,15 +154,52 @@
|
|
|
153
154
|
(println "Run 'git stash' or 'git commit' first.")
|
|
154
155
|
(System/exit 1))))
|
|
155
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
|
+
|
|
156
197
|
(defn- probe-model
|
|
157
198
|
"Send 'say ok' to a model via its harness CLI. Returns true if model responds.
|
|
158
|
-
|
|
159
|
-
[harness model]
|
|
199
|
+
Uses harness/build-probe-cmd for the command, /dev/null stdin to prevent hang."
|
|
200
|
+
[harness-kw model]
|
|
160
201
|
(try
|
|
161
|
-
(let [cmd (
|
|
162
|
-
:claude ["claude" "--model" model "-p" "[oompa:probe] say ok" "--max-turns" "1"]
|
|
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"])
|
|
202
|
+
(let [cmd (harness/build-probe-cmd harness-kw model)
|
|
165
203
|
null-in (io/input-stream (io/file "/dev/null"))
|
|
166
204
|
proc (process/process cmd {:out :string :err :string :in null-in})
|
|
167
205
|
result (deref proc 30000 :timeout)]
|
|
@@ -225,10 +263,10 @@
|
|
|
225
263
|
(println (format " %dx %s" (:count spec) (name (:harness spec)))))
|
|
226
264
|
(println)
|
|
227
265
|
(worker/run-workers! workers))
|
|
228
|
-
;; Simple mode
|
|
266
|
+
;; Simple mode retired — use oompa.json or --workers harness:count
|
|
229
267
|
(do
|
|
230
|
-
(println
|
|
231
|
-
(
|
|
268
|
+
(println "Simple mode is no longer supported. Use oompa.json or --workers harness:count.")
|
|
269
|
+
(System/exit 1))))))
|
|
232
270
|
|
|
233
271
|
(defn cmd-loop
|
|
234
272
|
"Run orchestrator N times"
|
|
@@ -257,14 +295,10 @@
|
|
|
257
295
|
(println (format " %dx %s" (:count spec) (name (:harness spec)))))
|
|
258
296
|
(println)
|
|
259
297
|
(worker/run-workers! workers))
|
|
260
|
-
;; Simple mode
|
|
261
|
-
(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
(println (format "Starting %d iterations with %s harness%s..."
|
|
265
|
-
iterations (name (:harness opts)) model-str))
|
|
266
|
-
(println (format "Swarm ID: %s" swarm-id))
|
|
267
|
-
(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)))))
|
|
268
302
|
|
|
269
303
|
(defn cmd-prompt
|
|
270
304
|
"Run ad-hoc prompt as single task"
|
|
@@ -284,46 +318,45 @@
|
|
|
284
318
|
(orchestrator/run-once! opts))))
|
|
285
319
|
|
|
286
320
|
(defn cmd-status
|
|
287
|
-
"Show status of last run — reads
|
|
321
|
+
"Show status of last run — reads event-sourced runs/{swarm-id}/ data."
|
|
288
322
|
[opts args]
|
|
289
323
|
(let [run-ids (runs/list-runs)]
|
|
290
324
|
(if (seq run-ids)
|
|
291
325
|
(let [swarm-id (or (first args) (first run-ids))
|
|
292
|
-
|
|
293
|
-
|
|
326
|
+
started (runs/read-started swarm-id)
|
|
327
|
+
stopped (runs/read-stopped swarm-id)
|
|
328
|
+
cycles (runs/list-cycles swarm-id)
|
|
294
329
|
reviews (runs/list-reviews swarm-id)]
|
|
295
330
|
(println (format "Swarm: %s" swarm-id))
|
|
296
|
-
(when
|
|
297
|
-
(println (format " Started: %s" (:started-at
|
|
298
|
-
(println (format "
|
|
299
|
-
(println (format "
|
|
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)))))
|
|
300
336
|
(println)
|
|
301
|
-
(if
|
|
302
|
-
(
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
(or (:errors w) 0)
|
|
319
|
-
(or (:review-rounds-total w) 0)))))
|
|
320
|
-
(println " (still running — no summary yet)"))
|
|
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) []))))))
|
|
321
354
|
(when (seq reviews)
|
|
322
355
|
(println)
|
|
323
356
|
(println (format "Reviews: %d total" (count reviews)))
|
|
324
357
|
(doseq [r reviews]
|
|
325
|
-
(println (format " %s-
|
|
326
|
-
(:worker-id r) (:
|
|
358
|
+
(println (format " %s-c%d-r%d: %s"
|
|
359
|
+
(:worker-id r) (:cycle r) (:round r)
|
|
327
360
|
(:verdict r))))))
|
|
328
361
|
;; Fall back to legacy JSONL format
|
|
329
362
|
(let [runs-dir (io/file "runs")
|
|
@@ -382,21 +415,43 @@
|
|
|
382
415
|
"Check if agent backends are available"
|
|
383
416
|
[opts args]
|
|
384
417
|
(println "Checking agent backends...")
|
|
385
|
-
(doseq [
|
|
386
|
-
(let [available? (
|
|
418
|
+
(doseq [harness-kw (sort (harness/known-harnesses))]
|
|
419
|
+
(let [available? (harness/check-available harness-kw)]
|
|
387
420
|
(println (format " %s: %s"
|
|
388
|
-
(name
|
|
421
|
+
(name harness-kw)
|
|
389
422
|
(if available? "✓ available" "✗ not found"))))))
|
|
390
423
|
|
|
424
|
+
(def ^:private reasoning-variants
|
|
425
|
+
#{"minimal" "low" "medium" "high" "max" "xhigh"})
|
|
426
|
+
|
|
391
427
|
(defn- parse-model-string
|
|
392
428
|
"Parse model string into {:harness :model :reasoning}.
|
|
393
|
-
|
|
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."
|
|
394
437
|
[s]
|
|
395
438
|
(if (and s (str/includes? s ":"))
|
|
396
|
-
(let [
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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.
|
|
400
455
|
{:harness :codex :model s}))
|
|
401
456
|
{:harness :codex :model s}))
|
|
402
457
|
|
|
@@ -414,7 +469,7 @@
|
|
|
414
469
|
(println " \"workers\": [")
|
|
415
470
|
(println " {\"model\": \"codex:gpt-5.3-codex:medium\", \"prompt\": \"prompts/executor.md\", \"iterations\": 10, \"count\": 3, \"can_plan\": false},")
|
|
416
471
|
(println " {\"model\": \"claude:opus\", \"prompt\": [\"prompts/base.md\", \"prompts/planner.md\"], \"count\": 1},")
|
|
417
|
-
(println " {\"model\": \"
|
|
472
|
+
(println " {\"model\": \"gemini:gemini-3-pro-preview\", \"prompt\": [\"prompts/executor.md\"], \"count\": 1}")
|
|
418
473
|
(println " ]")
|
|
419
474
|
(println "}")
|
|
420
475
|
(println)
|
|
@@ -422,6 +477,8 @@
|
|
|
422
477
|
(System/exit 1))
|
|
423
478
|
;; Preflight: abort if git is dirty to prevent merge conflicts
|
|
424
479
|
(check-git-clean!)
|
|
480
|
+
;; Preflight: abort if stale worktrees from prior runs would poison git
|
|
481
|
+
(check-stale-worktrees!)
|
|
425
482
|
|
|
426
483
|
(let [config (json/parse-string (slurp f) true)
|
|
427
484
|
;; Parse reviewer config — supports both formats:
|
|
@@ -476,6 +533,8 @@
|
|
|
476
533
|
:iterations (or (:iterations wc) 10)
|
|
477
534
|
:prompts (:prompt wc)
|
|
478
535
|
:can-plan (:can_plan wc)
|
|
536
|
+
:wait-between (:wait_between wc)
|
|
537
|
+
:max-working-resumes (:max_working_resumes wc)
|
|
479
538
|
:review-harness (:harness review-parsed)
|
|
480
539
|
:review-model (:model review-parsed)
|
|
481
540
|
:review-prompts (:prompts review-parsed)})))
|
|
@@ -516,13 +575,13 @@
|
|
|
516
575
|
planner-config (conj planner-config))
|
|
517
576
|
review-parsed)
|
|
518
577
|
|
|
519
|
-
;; Write
|
|
520
|
-
(runs/write-
|
|
578
|
+
;; Write started event to runs/{swarm-id}/started.json
|
|
579
|
+
(runs/write-started! swarm-id
|
|
521
580
|
{:workers workers
|
|
522
581
|
:planner-config planner-parsed
|
|
523
582
|
:reviewer-config review-parsed
|
|
524
583
|
:config-file config-file})
|
|
525
|
-
(println (format "\
|
|
584
|
+
(println (format "\nStarted event written to runs/%s/started.json" swarm-id))
|
|
526
585
|
|
|
527
586
|
;; Run planner if configured — synchronously before workers
|
|
528
587
|
(when planner-parsed
|
|
@@ -555,6 +614,69 @@
|
|
|
555
614
|
(doseq [t (tasks/list-current)]
|
|
556
615
|
(println (format " - %s: %s" (:id t) (:summary t)))))))
|
|
557
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
|
+
|
|
558
680
|
(defn cmd-help
|
|
559
681
|
"Print usage information"
|
|
560
682
|
[opts args]
|
|
@@ -570,6 +692,8 @@
|
|
|
570
692
|
(println " prompt \"...\" Run ad-hoc prompt")
|
|
571
693
|
(println " status Show last run summary")
|
|
572
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)")
|
|
573
697
|
(println " cleanup Remove all worktrees")
|
|
574
698
|
(println " context Print context block")
|
|
575
699
|
(println " check Check agent backends")
|
|
@@ -579,8 +703,8 @@
|
|
|
579
703
|
(println " --workers N Number of parallel workers (default: 2)")
|
|
580
704
|
(println " --workers H:N [H:N ...] Mixed workers by harness (e.g., claude:5 opencode:2)")
|
|
581
705
|
(println " --iterations N Number of iterations per worker (default: 1)")
|
|
582
|
-
(println " --harness {
|
|
583
|
-
(println " --model MODEL Model to use (e.g., codex-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)")
|
|
584
708
|
(println " --dry-run Skip actual merges")
|
|
585
709
|
(println " --keep-worktrees Don't cleanup worktrees after run")
|
|
586
710
|
(println)
|
|
@@ -600,6 +724,8 @@
|
|
|
600
724
|
"tasks" cmd-tasks
|
|
601
725
|
"prompt" cmd-prompt
|
|
602
726
|
"status" cmd-status
|
|
727
|
+
"stop" cmd-stop
|
|
728
|
+
"kill" cmd-kill
|
|
603
729
|
"worktrees" cmd-worktrees
|
|
604
730
|
"cleanup" cmd-cleanup
|
|
605
731
|
"context" cmd-context
|