@nbardy/oompa 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -16
- package/agentnet/src/agentnet/agent.clj +24 -1
- package/agentnet/src/agentnet/cli.clj +165 -38
- package/agentnet/src/agentnet/orchestrator.clj +1 -1
- package/agentnet/src/agentnet/runs.clj +165 -0
- package/agentnet/src/agentnet/schema.clj +4 -4
- package/agentnet/src/agentnet/tasks.clj +1 -0
- package/agentnet/src/agentnet/worker.clj +578 -299
- package/config/prompts/_task_header.md +8 -1
- package/oompa.example.json +8 -1
- package/package.json +3 -2
|
@@ -15,80 +15,12 @@
|
|
|
15
15
|
(:require [agentnet.tasks :as tasks]
|
|
16
16
|
[agentnet.agent :as agent]
|
|
17
17
|
[agentnet.worktree :as worktree]
|
|
18
|
+
[agentnet.runs :as runs]
|
|
19
|
+
[cheshire.core :as json]
|
|
18
20
|
[babashka.process :as process]
|
|
19
21
|
[clojure.java.io :as io]
|
|
20
22
|
[clojure.string :as str]))
|
|
21
23
|
|
|
22
|
-
;; =============================================================================
|
|
23
|
-
;; codex-persist integration
|
|
24
|
-
;; =============================================================================
|
|
25
|
-
|
|
26
|
-
(def ^:private persist-cmd* (atom nil))
|
|
27
|
-
(def ^:private persist-missing-warned?* (atom false))
|
|
28
|
-
|
|
29
|
-
(defn- command-ok?
|
|
30
|
-
"Return true if command vector is executable (exit code ignored)."
|
|
31
|
-
[cmd]
|
|
32
|
-
(try
|
|
33
|
-
(do
|
|
34
|
-
(process/sh (vec cmd) {:out :string :err :string :continue true})
|
|
35
|
-
true)
|
|
36
|
-
(catch Exception _
|
|
37
|
-
false)))
|
|
38
|
-
|
|
39
|
-
(defn- resolve-codex-persist-cmd
|
|
40
|
-
"Resolve codex-persist command vector.
|
|
41
|
-
Order:
|
|
42
|
-
1) CODEX_PERSIST_BIN env var
|
|
43
|
-
2) codex-persist on PATH
|
|
44
|
-
3) node ~/git/codex-persist/dist/cli.js"
|
|
45
|
-
[]
|
|
46
|
-
(let [cached @persist-cmd*]
|
|
47
|
-
(if (some? cached)
|
|
48
|
-
cached
|
|
49
|
-
(let [env-bin (System/getenv "CODEX_PERSIST_BIN")
|
|
50
|
-
env-cmd (when (and env-bin (not (str/blank? env-bin)))
|
|
51
|
-
[env-bin])
|
|
52
|
-
path-cmd ["codex-persist"]
|
|
53
|
-
local-cli (str (System/getProperty "user.home") "/git/codex-persist/dist/cli.js")
|
|
54
|
-
local-cmd (when (.exists (io/file local-cli))
|
|
55
|
-
["node" local-cli])
|
|
56
|
-
cmd (cond
|
|
57
|
-
(and env-cmd (command-ok? env-cmd)) env-cmd
|
|
58
|
-
(command-ok? path-cmd) path-cmd
|
|
59
|
-
(and local-cmd (command-ok? local-cmd)) local-cmd
|
|
60
|
-
:else false)]
|
|
61
|
-
(reset! persist-cmd* cmd)
|
|
62
|
-
cmd))))
|
|
63
|
-
|
|
64
|
-
(defn- safe-assistant-content
|
|
65
|
-
"Pick a non-empty assistant message payload for persistence."
|
|
66
|
-
[result]
|
|
67
|
-
(let [out (or (:out result) "")
|
|
68
|
-
err (or (:err result) "")
|
|
69
|
-
exit-code (or (:exit result) -1)]
|
|
70
|
-
(cond
|
|
71
|
-
(not (str/blank? out)) out
|
|
72
|
-
(not (str/blank? err)) (str "[agent stderr] " err)
|
|
73
|
-
:else (str "[agent exit " exit-code "]"))))
|
|
74
|
-
|
|
75
|
-
(defn- persist-message!
|
|
76
|
-
"Write a single message to codex-persist; no-op if unavailable."
|
|
77
|
-
[worker-id session-id cwd role content]
|
|
78
|
-
(let [resolved (resolve-codex-persist-cmd)]
|
|
79
|
-
(if (and resolved (not= resolved false))
|
|
80
|
-
(let [persist-cmd resolved
|
|
81
|
-
payload (if (str/blank? content) "(empty)" content)
|
|
82
|
-
result (try
|
|
83
|
-
(process/sh (into persist-cmd ["write" session-id cwd role payload])
|
|
84
|
-
{:out :string :err :string})
|
|
85
|
-
(catch Exception e
|
|
86
|
-
{:exit -1 :out "" :err (.getMessage e)}))]
|
|
87
|
-
(when-not (zero? (:exit result))
|
|
88
|
-
(println (format "[%s] codex-persist write failed (%s)" worker-id role))))
|
|
89
|
-
(when (compare-and-set! persist-missing-warned?* false true)
|
|
90
|
-
(println "[oompa] codex-persist not found; set CODEX_PERSIST_BIN or install/link codex-persist")))))
|
|
91
|
-
|
|
92
24
|
;; =============================================================================
|
|
93
25
|
;; Worker State
|
|
94
26
|
;; =============================================================================
|
|
@@ -97,6 +29,10 @@
|
|
|
97
29
|
"Root of the oompa package — set by bin/oompa.js, falls back to cwd."
|
|
98
30
|
(or (System/getenv "OOMPA_PACKAGE_ROOT") "."))
|
|
99
31
|
|
|
32
|
+
;; Serializes merge-to-main! calls across concurrent workers to prevent
|
|
33
|
+
;; git index corruption from parallel checkout+merge operations.
|
|
34
|
+
(def ^:private merge-lock (Object.))
|
|
35
|
+
|
|
100
36
|
;; Resolve absolute paths for CLI binaries at first use.
|
|
101
37
|
;; ProcessBuilder with :dir set can fail to find bare command names on some
|
|
102
38
|
;; platforms (macOS + babashka), so we resolve once via `which` and cache.
|
|
@@ -127,8 +63,10 @@
|
|
|
127
63
|
"Create a worker config.
|
|
128
64
|
:prompts is a string or vector of strings — paths to prompt files.
|
|
129
65
|
:can-plan when false, worker waits for tasks before starting (backpressure).
|
|
130
|
-
:reasoning reasoning effort level (e.g. \"low\", \"medium\", \"high\") — codex only.
|
|
131
|
-
|
|
66
|
+
:reasoning reasoning effort level (e.g. \"low\", \"medium\", \"high\") — codex only.
|
|
67
|
+
:review-prompts paths to reviewer prompt files (loaded and concatenated for review)."
|
|
68
|
+
[{:keys [id swarm-id harness model iterations prompts can-plan reasoning
|
|
69
|
+
review-harness review-model review-prompts]}]
|
|
132
70
|
{:id id
|
|
133
71
|
:swarm-id swarm-id
|
|
134
72
|
:harness (or harness :codex)
|
|
@@ -142,6 +80,10 @@
|
|
|
142
80
|
:reasoning reasoning
|
|
143
81
|
:review-harness review-harness
|
|
144
82
|
:review-model review-model
|
|
83
|
+
:review-prompts (cond
|
|
84
|
+
(vector? review-prompts) review-prompts
|
|
85
|
+
(string? review-prompts) [review-prompts]
|
|
86
|
+
:else [])
|
|
145
87
|
:completed 0
|
|
146
88
|
:status :idle})
|
|
147
89
|
|
|
@@ -164,33 +106,83 @@
|
|
|
164
106
|
:task_status (format "Pending: %d, In Progress: %d, Complete: %d"
|
|
165
107
|
(count pending) (count current) (count complete))}))
|
|
166
108
|
|
|
109
|
+
(defn- opencode-attach-url
|
|
110
|
+
"Optional opencode server URL for run --attach mode."
|
|
111
|
+
[]
|
|
112
|
+
(let [url (or (System/getenv "OOMPA_OPENCODE_ATTACH")
|
|
113
|
+
(System/getenv "OPENCODE_ATTACH"))]
|
|
114
|
+
(when (and url (not (str/blank? url)))
|
|
115
|
+
url)))
|
|
116
|
+
|
|
117
|
+
(defn- parse-opencode-run-output
|
|
118
|
+
"Parse `opencode run --format json` output.
|
|
119
|
+
Returns {:session-id string|nil, :text string|nil}."
|
|
120
|
+
[s]
|
|
121
|
+
(let [raw (or s "")
|
|
122
|
+
events (->> (str/split-lines raw)
|
|
123
|
+
(keep (fn [line]
|
|
124
|
+
(try
|
|
125
|
+
(json/parse-string line true)
|
|
126
|
+
(catch Exception _
|
|
127
|
+
nil))))
|
|
128
|
+
doall)
|
|
129
|
+
session-id (or (some #(or (:sessionID %)
|
|
130
|
+
(:sessionId %)
|
|
131
|
+
(get-in % [:part :sessionID])
|
|
132
|
+
(get-in % [:part :sessionId]))
|
|
133
|
+
events)
|
|
134
|
+
(some-> (re-find #"(ses_[A-Za-z0-9]+)" raw) second))
|
|
135
|
+
text (->> events
|
|
136
|
+
(keep (fn [event]
|
|
137
|
+
(let [event-type (or (:type event) (get-in event [:part :type]))
|
|
138
|
+
chunk (or (:text event) (get-in event [:part :text]))]
|
|
139
|
+
(when (and (= event-type "text")
|
|
140
|
+
(string? chunk)
|
|
141
|
+
(not (str/blank? chunk)))
|
|
142
|
+
chunk))))
|
|
143
|
+
(str/join ""))]
|
|
144
|
+
{:session-id session-id
|
|
145
|
+
:text (when-not (str/blank? text) text)}))
|
|
146
|
+
|
|
167
147
|
(defn- run-agent!
|
|
168
|
-
"Run agent with prompt, return {:output string, :done? bool, :exit int}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
148
|
+
"Run agent with prompt, return {:output string, :done? bool, :merge? bool, :exit int, :session-id string}.
|
|
149
|
+
When resume? is true and harness is :claude/:opencode, continues the existing session
|
|
150
|
+
with a lighter prompt (just task status + continue instruction)."
|
|
151
|
+
[{:keys [id swarm-id harness model prompts reasoning]} worktree-path context session-id resume?]
|
|
152
|
+
(let [;; Use provided session-id, otherwise generate one for harnesses that accept custom IDs.
|
|
153
|
+
session-id (or session-id
|
|
154
|
+
(when (#{:codex :claude} harness)
|
|
155
|
+
(str/lower-case (str (java.util.UUID/randomUUID)))))
|
|
156
|
+
|
|
157
|
+
;; Build prompt — lighter for resume (agent already has full context)
|
|
158
|
+
prompt (if resume?
|
|
159
|
+
(str "Task Status: " (:task_status context) "\n"
|
|
160
|
+
"Pending: " (:pending_tasks context) "\n\n"
|
|
161
|
+
"Continue working. Signal COMPLETE_AND_READY_FOR_MERGE when your current task is done and ready for review.")
|
|
162
|
+
(let [task-header (or (load-prompt "config/prompts/_task_header.md") "")
|
|
163
|
+
user-prompts (if (seq prompts)
|
|
164
|
+
(->> prompts
|
|
165
|
+
(map load-prompt)
|
|
166
|
+
(remove nil?)
|
|
167
|
+
(str/join "\n\n"))
|
|
168
|
+
(or (load-prompt "config/prompts/worker.md")
|
|
169
|
+
"You are a worker. Claim tasks, execute them, complete them."))]
|
|
170
|
+
(str task-header "\n"
|
|
171
|
+
"Task Status: " (:task_status context) "\n"
|
|
172
|
+
"Pending: " (:pending_tasks context) "\n\n"
|
|
173
|
+
user-prompts)))
|
|
174
|
+
|
|
188
175
|
swarm-id* (or swarm-id "unknown")
|
|
189
|
-
tagged-prompt (str "[oompa:" swarm-id* ":" id "] "
|
|
176
|
+
tagged-prompt (str "[oompa:" swarm-id* ":" id "] " prompt)
|
|
190
177
|
abs-worktree (.getAbsolutePath (io/file worktree-path))
|
|
178
|
+
opencode-attach (opencode-attach-url)
|
|
191
179
|
|
|
192
|
-
;; Build command —
|
|
180
|
+
;; Build command — all harnesses run with cwd=worktree, no sandbox
|
|
193
181
|
;; so agents can `..` to reach project root for task management
|
|
182
|
+
;; Claude: --resume flag continues existing session-id conversation
|
|
183
|
+
;; Opencode: -s/--session + --continue continue existing session
|
|
184
|
+
;; and --format json for deterministic per-run session capture.
|
|
185
|
+
;; Codex: no native resume support, always fresh (but worktree state persists)
|
|
194
186
|
cmd (case harness
|
|
195
187
|
:codex (cond-> [(resolve-binary! "codex") "exec"
|
|
196
188
|
"--dangerously-bypass-approvals-and-sandbox"
|
|
@@ -198,54 +190,86 @@
|
|
|
198
190
|
"-C" abs-worktree]
|
|
199
191
|
model (into ["--model" model])
|
|
200
192
|
reasoning (into ["-c" (str "model_reasoning_effort=\"" reasoning "\"")])
|
|
201
|
-
true (conj "--"
|
|
193
|
+
true (conj "--" tagged-prompt))
|
|
202
194
|
:claude (cond-> [(resolve-binary! "claude") "-p" "--dangerously-skip-permissions"
|
|
203
195
|
"--session-id" session-id]
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
196
|
+
resume? (conj "--resume")
|
|
197
|
+
model (into ["--model" model]))
|
|
198
|
+
:opencode (cond-> [(resolve-binary! "opencode") "run" "--format" "json"]
|
|
199
|
+
model (into ["-m" model])
|
|
200
|
+
opencode-attach (into ["--attach" opencode-attach])
|
|
201
|
+
(and resume? session-id) (into ["-s" session-id "--continue"])
|
|
202
|
+
true (conj tagged-prompt)))
|
|
203
|
+
|
|
204
|
+
;; Run agent — all run with cwd=worktree
|
|
210
205
|
result (try
|
|
211
206
|
(if (= harness :claude)
|
|
212
207
|
(process/sh cmd {:dir abs-worktree :in tagged-prompt :out :string :err :string})
|
|
213
208
|
(process/sh cmd {:dir abs-worktree :out :string :err :string}))
|
|
214
209
|
(catch Exception e
|
|
215
210
|
(println (format "[%s] Agent exception: %s" id (.getMessage e)))
|
|
216
|
-
{:exit -1 :out "" :err (.getMessage e)}))
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
211
|
+
{:exit -1 :out "" :err (.getMessage e)}))
|
|
212
|
+
parsed-opencode (when (= harness :opencode)
|
|
213
|
+
(parse-opencode-run-output (:out result)))
|
|
214
|
+
output (if (= harness :opencode)
|
|
215
|
+
(or (:text parsed-opencode) (:out result))
|
|
216
|
+
(:out result))
|
|
217
|
+
session-id' (if (= harness :opencode)
|
|
218
|
+
(or (:session-id parsed-opencode) session-id)
|
|
219
|
+
session-id)]
|
|
220
|
+
|
|
221
|
+
{:output output
|
|
222
222
|
:exit (:exit result)
|
|
223
|
-
:done? (agent/done-signal?
|
|
223
|
+
:done? (agent/done-signal? output)
|
|
224
|
+
:merge? (agent/merge-signal? output)
|
|
225
|
+
:session-id session-id'}))
|
|
224
226
|
|
|
225
227
|
(defn- run-reviewer!
|
|
226
228
|
"Run reviewer on worktree changes.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
229
|
+
Uses custom review-prompts when configured, otherwise falls back to default.
|
|
230
|
+
prev-feedback: vector of previous review outputs (for multi-round context).
|
|
231
|
+
Returns {:verdict :approved|:needs-changes|:rejected, :comments [...], :output string}"
|
|
232
|
+
[{:keys [id swarm-id review-harness review-model review-prompts]} worktree-path prev-feedback]
|
|
233
|
+
(let [;; Get actual diff content (not just stat) — truncate to 8000 chars for prompt budget
|
|
234
|
+
diff-result (process/sh ["git" "diff" "main"]
|
|
231
235
|
{:dir worktree-path :out :string :err :string})
|
|
232
|
-
diff-
|
|
236
|
+
diff-content (let [d (:out diff-result)]
|
|
237
|
+
(if (> (count d) 8000)
|
|
238
|
+
(str (subs d 0 8000) "\n... [diff truncated at 8000 chars]")
|
|
239
|
+
d))
|
|
233
240
|
|
|
234
|
-
;; Build review prompt
|
|
241
|
+
;; Build review prompt — use custom prompts if configured, else default
|
|
235
242
|
swarm-id* (or swarm-id "unknown")
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
243
|
+
custom-prompt (when (seq review-prompts)
|
|
244
|
+
(->> review-prompts
|
|
245
|
+
(map load-prompt)
|
|
246
|
+
(remove nil?)
|
|
247
|
+
(str/join "\n\n")))
|
|
248
|
+
|
|
249
|
+
;; Include previous review history for multi-round context
|
|
250
|
+
history-block (when (seq prev-feedback)
|
|
251
|
+
(str "\n## Previous Review Rounds\n\n"
|
|
252
|
+
"The worker has already attempted fixes based on earlier feedback. "
|
|
253
|
+
"Do NOT raise new issues — only verify the original issues are resolved.\n\n"
|
|
254
|
+
(->> prev-feedback
|
|
255
|
+
(map-indexed (fn [i fb]
|
|
256
|
+
(str "### Round " (inc i) " feedback:\n" fb)))
|
|
257
|
+
(str/join "\n\n"))
|
|
258
|
+
"\n\n"))
|
|
259
|
+
|
|
260
|
+
review-body (str (or custom-prompt
|
|
261
|
+
(str "Review the changes in this worktree.\n"
|
|
262
|
+
"Focus on architecture and design, not style.\n"))
|
|
263
|
+
"\n\nDiff:\n```\n" diff-content "\n```\n"
|
|
264
|
+
(when history-block history-block)
|
|
265
|
+
"\nYour verdict MUST be on its own line, exactly one of:\n"
|
|
266
|
+
"VERDICT: APPROVED\n"
|
|
267
|
+
"VERDICT: NEEDS_CHANGES\n"
|
|
268
|
+
"VERDICT: REJECTED\n")
|
|
269
|
+
review-prompt (str "[oompa:" swarm-id* ":" id "] " review-body)
|
|
247
270
|
|
|
248
271
|
abs-wt (.getAbsolutePath (io/file worktree-path))
|
|
272
|
+
opencode-attach (opencode-attach-url)
|
|
249
273
|
|
|
250
274
|
;; Build command — cwd=worktree, no sandbox
|
|
251
275
|
cmd (case review-harness
|
|
@@ -256,7 +280,11 @@
|
|
|
256
280
|
review-model (into ["--model" review-model])
|
|
257
281
|
true (conj "--" review-prompt))
|
|
258
282
|
:claude (cond-> [(resolve-binary! "claude") "-p" "--dangerously-skip-permissions"]
|
|
259
|
-
review-model (into ["--model" review-model]))
|
|
283
|
+
review-model (into ["--model" review-model]))
|
|
284
|
+
:opencode (cond-> [(resolve-binary! "opencode") "run"]
|
|
285
|
+
review-model (into ["-m" review-model])
|
|
286
|
+
opencode-attach (into ["--attach" opencode-attach])
|
|
287
|
+
true (conj review-prompt)))
|
|
260
288
|
|
|
261
289
|
;; Run reviewer — cwd=worktree
|
|
262
290
|
result (try
|
|
@@ -266,27 +294,50 @@
|
|
|
266
294
|
(catch Exception e
|
|
267
295
|
{:exit -1 :out "" :err (.getMessage e)}))
|
|
268
296
|
|
|
269
|
-
output (:out result)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
297
|
+
output (:out result)
|
|
298
|
+
|
|
299
|
+
;; Parse verdict — require explicit VERDICT: prefix to avoid false matches
|
|
300
|
+
verdict (cond
|
|
301
|
+
(re-find #"VERDICT:\s*APPROVED" output) :approved
|
|
302
|
+
(re-find #"VERDICT:\s*REJECTED" output) :rejected
|
|
303
|
+
(re-find #"VERDICT:\s*NEEDS_CHANGES" output) :needs-changes
|
|
304
|
+
;; Fallback to loose matching if reviewer didn't use prefix
|
|
305
|
+
(re-find #"(?i)\bAPPROVED\b" output) :approved
|
|
306
|
+
(re-find #"(?i)\bREJECTED\b" output) :rejected
|
|
307
|
+
:else :needs-changes)]
|
|
308
|
+
|
|
309
|
+
;; Log reviewer output (truncated) for visibility
|
|
310
|
+
(println (format "[%s] Reviewer verdict: %s" id (name verdict)))
|
|
311
|
+
(let [summary (subs output 0 (min 300 (count output)))]
|
|
312
|
+
(println (format "[%s] Review: %s%s" id summary
|
|
313
|
+
(if (> (count output) 300) "..." ""))))
|
|
314
|
+
|
|
315
|
+
{:verdict verdict
|
|
275
316
|
:comments (when (not= (:exit result) 0)
|
|
276
317
|
[(:err result)])
|
|
277
318
|
:output output}))
|
|
278
319
|
|
|
279
320
|
(defn- run-fix!
|
|
280
321
|
"Ask worker to fix issues based on reviewer feedback.
|
|
322
|
+
all-feedback: vector of all reviewer outputs so far (accumulated across rounds).
|
|
281
323
|
Returns {:output string, :exit int}"
|
|
282
|
-
[{:keys [id swarm-id harness model]} worktree-path feedback]
|
|
324
|
+
[{:keys [id swarm-id harness model]} worktree-path all-feedback]
|
|
283
325
|
(let [swarm-id* (or swarm-id "unknown")
|
|
326
|
+
feedback-text (if (> (count all-feedback) 1)
|
|
327
|
+
(str "The reviewer has given feedback across " (count all-feedback) " rounds.\n"
|
|
328
|
+
"Fix ALL outstanding issues:\n\n"
|
|
329
|
+
(->> all-feedback
|
|
330
|
+
(map-indexed (fn [i fb]
|
|
331
|
+
(str "--- Round " (inc i) " ---\n" fb)))
|
|
332
|
+
(str/join "\n\n")))
|
|
333
|
+
(str "The reviewer found issues with your changes:\n\n"
|
|
334
|
+
(first all-feedback)))
|
|
284
335
|
fix-prompt (str "[oompa:" swarm-id* ":" id "] "
|
|
285
|
-
"
|
|
286
|
-
|
|
287
|
-
"Please fix these issues in the worktree.")
|
|
336
|
+
feedback-text "\n\n"
|
|
337
|
+
"Fix these issues. Do not add anything the reviewer did not ask for.")
|
|
288
338
|
|
|
289
339
|
abs-wt (.getAbsolutePath (io/file worktree-path))
|
|
340
|
+
opencode-attach (opencode-attach-url)
|
|
290
341
|
|
|
291
342
|
cmd (case harness
|
|
292
343
|
:codex (cond-> [(resolve-binary! "codex") "exec"
|
|
@@ -296,7 +347,11 @@
|
|
|
296
347
|
model (into ["--model" model])
|
|
297
348
|
true (conj "--" fix-prompt))
|
|
298
349
|
:claude (cond-> [(resolve-binary! "claude") "-p" "--dangerously-skip-permissions"]
|
|
299
|
-
model (into ["--model" model]))
|
|
350
|
+
model (into ["--model" model]))
|
|
351
|
+
:opencode (cond-> [(resolve-binary! "opencode") "run"]
|
|
352
|
+
model (into ["-m" model])
|
|
353
|
+
opencode-attach (into ["--attach" opencode-attach])
|
|
354
|
+
true (conj fix-prompt)))
|
|
300
355
|
|
|
301
356
|
result (try
|
|
302
357
|
(if (= harness :claude)
|
|
@@ -309,142 +364,173 @@
|
|
|
309
364
|
:exit (:exit result)}))
|
|
310
365
|
|
|
311
366
|
(defn- worktree-has-changes?
|
|
312
|
-
"Check if worktree has
|
|
367
|
+
"Check if worktree has committed OR uncommitted changes vs main.
|
|
368
|
+
Workers commit before signaling merge, so we must check both:
|
|
369
|
+
1. Uncommitted changes (git status --porcelain)
|
|
370
|
+
2. Commits ahead of main (git rev-list --count main..HEAD)"
|
|
313
371
|
[wt-path]
|
|
314
|
-
(let [
|
|
315
|
-
|
|
372
|
+
(let [uncommitted (process/sh ["git" "status" "--porcelain"]
|
|
373
|
+
{:dir wt-path :out :string :err :string})
|
|
374
|
+
ahead (process/sh ["git" "rev-list" "--count" "main..HEAD"]
|
|
375
|
+
{:dir wt-path :out :string :err :string})
|
|
376
|
+
ahead-count (try (Integer/parseInt (str/trim (:out ahead)))
|
|
377
|
+
(catch Exception _ 0))]
|
|
378
|
+
(or (not (str/blank? (:out uncommitted)))
|
|
379
|
+
(pos? ahead-count))))
|
|
380
|
+
|
|
381
|
+
(defn- create-iteration-worktree!
|
|
382
|
+
"Create a fresh worktree for an iteration. Returns {:dir :branch :path}.
|
|
383
|
+
Force-removes stale worktree+branch from previous failed runs first."
|
|
384
|
+
[project-root worker-id iteration]
|
|
385
|
+
(let [wt-dir (format ".w%s-i%d" worker-id iteration)
|
|
386
|
+
wt-branch (format "oompa/%s-i%d" worker-id iteration)
|
|
387
|
+
wt-path (str project-root "/" wt-dir)]
|
|
388
|
+
;; Clean stale worktree/branch from previous failed runs
|
|
389
|
+
(process/sh ["git" "worktree" "remove" wt-dir "--force"] {:dir project-root})
|
|
390
|
+
(process/sh ["git" "branch" "-D" wt-branch] {:dir project-root})
|
|
391
|
+
(let [result (process/sh ["git" "worktree" "add" wt-dir "-b" wt-branch]
|
|
392
|
+
{:dir project-root :out :string :err :string})]
|
|
393
|
+
(when-not (zero? (:exit result))
|
|
394
|
+
(throw (ex-info (str "Failed to create worktree: " (:err result))
|
|
395
|
+
{:dir wt-dir :branch wt-branch}))))
|
|
396
|
+
{:dir wt-dir :branch wt-branch :path wt-path}))
|
|
397
|
+
|
|
398
|
+
(defn- cleanup-worktree!
|
|
399
|
+
"Remove worktree and branch."
|
|
400
|
+
[project-root wt-dir wt-branch]
|
|
401
|
+
(process/sh ["git" "worktree" "remove" wt-dir "--force"] {:dir project-root})
|
|
402
|
+
(process/sh ["git" "branch" "-D" wt-branch] {:dir project-root}))
|
|
403
|
+
|
|
404
|
+
(defn- get-head-hash
|
|
405
|
+
"Get the short HEAD commit hash."
|
|
406
|
+
[dir]
|
|
407
|
+
(let [result (process/sh ["git" "rev-parse" "--short" "HEAD"]
|
|
408
|
+
{:dir dir :out :string :err :string})]
|
|
409
|
+
(when (zero? (:exit result))
|
|
410
|
+
(str/trim (:out result)))))
|
|
411
|
+
|
|
412
|
+
(defn- annotate-completed-tasks!
|
|
413
|
+
"After a successful merge (called under merge-lock), annotate any tasks in
|
|
414
|
+
complete/ that lack metadata. Adds :completed-by, :completed-at,
|
|
415
|
+
:review-rounds, :merged-commit."
|
|
416
|
+
[project-root worker-id review-rounds]
|
|
417
|
+
(let [commit-hash (get-head-hash project-root)
|
|
418
|
+
complete-dir (io/file project-root "tasks" "complete")]
|
|
419
|
+
(when (.exists complete-dir)
|
|
420
|
+
(doseq [f (.listFiles complete-dir)]
|
|
421
|
+
(when (str/ends-with? (.getName f) ".edn")
|
|
422
|
+
(try
|
|
423
|
+
(let [task (read-string (slurp f))]
|
|
424
|
+
(when-not (:completed-by task)
|
|
425
|
+
(spit f (pr-str (assoc task
|
|
426
|
+
:completed-by worker-id
|
|
427
|
+
:completed-at (str (java.time.Instant/now))
|
|
428
|
+
:review-rounds (or review-rounds 0)
|
|
429
|
+
:merged-commit (or commit-hash "unknown"))))))
|
|
430
|
+
(catch Exception e
|
|
431
|
+
(println (format "[%s] Failed to annotate task %s: %s"
|
|
432
|
+
worker-id (.getName f) (.getMessage e))))))))))
|
|
316
433
|
|
|
317
434
|
(defn- merge-to-main!
|
|
318
|
-
"Merge worktree changes to main branch
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
435
|
+
"Merge worktree changes to main branch. Serialized via merge-lock to prevent
|
|
436
|
+
concurrent workers from corrupting the git index. On success, annotates any
|
|
437
|
+
newly-completed tasks with worker metadata. Returns true on success.
|
|
438
|
+
review-rounds: number of review rounds (0 for auto-merged task-only changes)."
|
|
439
|
+
[wt-path wt-id worker-id project-root review-rounds]
|
|
440
|
+
(locking merge-lock
|
|
441
|
+
(println (format "[%s] Merging changes to main" worker-id))
|
|
442
|
+
(let [;; Commit in worktree if needed (no-op if already committed)
|
|
443
|
+
_ (process/sh ["git" "add" "-A"] {:dir wt-path})
|
|
444
|
+
_ (process/sh ["git" "commit" "-m" (str "Work from " wt-id)]
|
|
445
|
+
{:dir wt-path})
|
|
446
|
+
;; Checkout main and merge (in project root, not worktree)
|
|
447
|
+
checkout-result (process/sh ["git" "checkout" "main"]
|
|
448
|
+
{:dir project-root :out :string :err :string})
|
|
449
|
+
_ (when-not (zero? (:exit checkout-result))
|
|
450
|
+
(println (format "[%s] MERGE FAILED: could not checkout main: %s"
|
|
451
|
+
worker-id (:err checkout-result))))
|
|
452
|
+
merge-result (when (zero? (:exit checkout-result))
|
|
453
|
+
(process/sh ["git" "merge" wt-id "--no-edit"]
|
|
454
|
+
{:dir project-root :out :string :err :string}))
|
|
455
|
+
success (and (zero? (:exit checkout-result))
|
|
456
|
+
(zero? (:exit merge-result)))]
|
|
457
|
+
(if success
|
|
458
|
+
(do
|
|
459
|
+
(println (format "[%s] Merge successful" worker-id))
|
|
460
|
+
;; Annotate completed tasks while still holding merge-lock
|
|
461
|
+
(annotate-completed-tasks! project-root worker-id review-rounds))
|
|
462
|
+
(when merge-result
|
|
463
|
+
(println (format "[%s] MERGE FAILED: %s" worker-id (:err merge-result)))))
|
|
464
|
+
success)))
|
|
465
|
+
|
|
466
|
+
(defn- task-only-diff?
|
|
467
|
+
"Check if all changes in worktree are task files only (no code changes).
|
|
468
|
+
Returns true if diff only touches files under tasks/ directory."
|
|
469
|
+
[wt-path]
|
|
470
|
+
(let [result (process/sh ["git" "diff" "main" "--name-only"]
|
|
471
|
+
{:dir wt-path :out :string :err :string})
|
|
472
|
+
files (when (zero? (:exit result))
|
|
473
|
+
(->> (str/split-lines (:out result))
|
|
474
|
+
(remove str/blank?)))]
|
|
475
|
+
(and (seq files)
|
|
476
|
+
(every? #(str/starts-with? % "tasks/") files))))
|
|
477
|
+
|
|
478
|
+
(defn- diff-file-names
|
|
479
|
+
"Get list of changed file names vs main."
|
|
480
|
+
[wt-path]
|
|
481
|
+
(let [result (process/sh ["git" "diff" "main" "--name-only"]
|
|
482
|
+
{:dir wt-path :out :string :err :string})]
|
|
483
|
+
(when (zero? (:exit result))
|
|
484
|
+
(->> (str/split-lines (:out result))
|
|
485
|
+
(remove str/blank?)
|
|
486
|
+
vec))))
|
|
333
487
|
|
|
334
488
|
(defn- review-loop!
|
|
335
489
|
"Run review loop: reviewer checks → if issues, fix & retry → back to reviewer.
|
|
490
|
+
Accumulates feedback across rounds so reviewer doesn't raise new issues
|
|
491
|
+
and fixer has full context of all prior feedback.
|
|
492
|
+
Writes review logs to runs/{swarm-id}/reviews/ for post-mortem analysis.
|
|
336
493
|
Returns {:approved? bool, :attempts int}"
|
|
337
|
-
[worker wt-path worker-id]
|
|
494
|
+
[worker wt-path worker-id iteration]
|
|
338
495
|
(if-not (and (:review-harness worker) (:review-model worker))
|
|
339
496
|
;; No reviewer configured, auto-approve
|
|
340
497
|
{:approved? true :attempts 0}
|
|
341
498
|
|
|
342
|
-
;; Run review loop
|
|
343
|
-
(loop [attempt 1
|
|
499
|
+
;; Run review loop with accumulated feedback
|
|
500
|
+
(loop [attempt 1
|
|
501
|
+
prev-feedback []]
|
|
344
502
|
(println (format "[%s] Review attempt %d/%d" worker-id attempt max-review-retries))
|
|
345
|
-
(let [{:keys [verdict output]} (run-reviewer! worker wt-path)
|
|
503
|
+
(let [{:keys [verdict output]} (run-reviewer! worker wt-path prev-feedback)
|
|
504
|
+
diff-files (diff-file-names wt-path)]
|
|
505
|
+
|
|
506
|
+
;; Persist review log for this round
|
|
507
|
+
(when (:swarm-id worker)
|
|
508
|
+
(runs/write-review-log! (:swarm-id worker) worker-id iteration attempt
|
|
509
|
+
{:verdict verdict
|
|
510
|
+
:output output
|
|
511
|
+
:diff-files (or diff-files [])}))
|
|
512
|
+
|
|
346
513
|
(case verdict
|
|
347
514
|
:approved
|
|
348
515
|
(do
|
|
349
|
-
(println (format "[%s] Reviewer APPROVED" worker-id))
|
|
516
|
+
(println (format "[%s] Reviewer APPROVED (attempt %d)" worker-id attempt))
|
|
350
517
|
{:approved? true :attempts attempt})
|
|
351
518
|
|
|
352
519
|
:rejected
|
|
353
520
|
(do
|
|
354
|
-
(println (format "[%s] Reviewer REJECTED" worker-id))
|
|
521
|
+
(println (format "[%s] Reviewer REJECTED (attempt %d)" worker-id attempt))
|
|
355
522
|
{:approved? false :attempts attempt})
|
|
356
523
|
|
|
357
524
|
;; :needs-changes
|
|
358
|
-
(
|
|
359
|
-
(
|
|
360
|
-
(println (format "[%s] Max review retries reached" worker-id))
|
|
361
|
-
{:approved? false :attempts attempt})
|
|
362
|
-
(do
|
|
363
|
-
(println (format "[%s] Reviewer requested changes, fixing..." worker-id))
|
|
364
|
-
(run-fix! worker wt-path output)
|
|
365
|
-
(recur (inc attempt)))))))))
|
|
366
|
-
|
|
367
|
-
(defn execute-iteration!
|
|
368
|
-
"Execute one iteration of work.
|
|
369
|
-
|
|
370
|
-
Flow:
|
|
371
|
-
1. Create worktree
|
|
372
|
-
2. Run agent
|
|
373
|
-
3. If reviewer configured: run review loop (fix → retry → reviewer)
|
|
374
|
-
4. If approved: merge to main
|
|
375
|
-
5. Cleanup worktree
|
|
376
|
-
|
|
377
|
-
Returns {:status :done|:continue|:error, :task task-or-nil}"
|
|
378
|
-
[worker iteration total-iterations]
|
|
379
|
-
(let [worker-id (:id worker)
|
|
380
|
-
project-root (System/getProperty "user.dir")
|
|
381
|
-
wt-dir (format ".w%s-i%d" worker-id iteration)
|
|
382
|
-
wt-branch (format "oompa/%s-i%d" worker-id iteration)
|
|
383
|
-
|
|
384
|
-
;; Create worktree
|
|
385
|
-
_ (println (format "[%s] Starting iteration %d/%d" worker-id iteration total-iterations))
|
|
386
|
-
wt-path (str project-root "/" wt-dir)]
|
|
387
|
-
|
|
388
|
-
(try
|
|
389
|
-
;; Clean stale worktree/branch from previous failed runs before creating
|
|
390
|
-
(process/sh ["git" "worktree" "remove" wt-dir "--force"] {:dir project-root})
|
|
391
|
-
(process/sh ["git" "branch" "-D" wt-branch] {:dir project-root})
|
|
392
|
-
|
|
393
|
-
;; Setup worktree (in project root)
|
|
394
|
-
(let [wt-result (process/sh ["git" "worktree" "add" wt-dir "-b" wt-branch]
|
|
395
|
-
{:dir project-root :out :string :err :string})]
|
|
396
|
-
(when-not (zero? (:exit wt-result))
|
|
397
|
-
(throw (ex-info (str "Failed to create worktree: " (:err wt-result))
|
|
398
|
-
{:dir wt-dir :branch wt-branch}))))
|
|
399
|
-
|
|
400
|
-
;; Build context
|
|
401
|
-
(let [context (build-context)
|
|
402
|
-
|
|
403
|
-
;; Run agent
|
|
404
|
-
{:keys [output exit done?]} (run-agent! worker wt-path context)]
|
|
405
|
-
|
|
406
|
-
(cond
|
|
407
|
-
;; Agent signaled done — only honor for can_plan workers.
|
|
408
|
-
;; Executors (can_plan: false) can't decide work is done.
|
|
409
|
-
(and done? (:can-plan worker))
|
|
410
|
-
(do
|
|
411
|
-
(println (format "[%s] Received __DONE__ signal" worker-id))
|
|
412
|
-
{:status :done})
|
|
413
|
-
|
|
414
|
-
;; can_plan: false worker said __DONE__ — ignore it, treat as success
|
|
415
|
-
(and done? (not (:can-plan worker)))
|
|
416
|
-
(do
|
|
417
|
-
(println (format "[%s] Ignoring __DONE__ (executor cannot signal done)" worker-id))
|
|
418
|
-
{:status :continue})
|
|
419
|
-
|
|
420
|
-
;; Agent errored
|
|
421
|
-
(not (zero? exit))
|
|
422
|
-
(do
|
|
423
|
-
(println (format "[%s] Agent error (exit %d): %s" worker-id exit (subs (or output "") 0 (min 200 (count (or output ""))))))
|
|
424
|
-
{:status :error :exit exit})
|
|
425
|
-
|
|
426
|
-
;; Agent produced no file changes — wasted iteration
|
|
427
|
-
(not (worktree-has-changes? wt-path))
|
|
428
|
-
(do
|
|
429
|
-
(println (format "[%s] No changes produced, skipping review" worker-id))
|
|
430
|
-
{:status :no-changes})
|
|
431
|
-
|
|
432
|
-
;; Success with changes - run review loop before merge
|
|
433
|
-
:else
|
|
434
|
-
(let [{:keys [approved?]} (review-loop! worker wt-path worker-id)]
|
|
435
|
-
(if approved?
|
|
525
|
+
(let [all-feedback (conj prev-feedback output)]
|
|
526
|
+
(if (>= attempt max-review-retries)
|
|
436
527
|
(do
|
|
437
|
-
(
|
|
438
|
-
|
|
439
|
-
{:status :continue})
|
|
528
|
+
(println (format "[%s] Max review retries reached (%d rounds)" worker-id attempt))
|
|
529
|
+
{:approved? false :attempts attempt})
|
|
440
530
|
(do
|
|
441
|
-
(println (format "[%s]
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
(finally
|
|
445
|
-
;; Cleanup worktree and branch (in project root)
|
|
446
|
-
(process/sh ["git" "worktree" "remove" wt-dir "--force"] {:dir project-root})
|
|
447
|
-
(process/sh ["git" "branch" "-D" wt-branch] {:dir project-root})))))
|
|
531
|
+
(println (format "[%s] Reviewer requested changes, fixing..." worker-id))
|
|
532
|
+
(run-fix! worker wt-path all-feedback)
|
|
533
|
+
(recur (inc attempt) all-feedback)))))))))
|
|
448
534
|
|
|
449
535
|
;; =============================================================================
|
|
450
536
|
;; Worker Loop
|
|
@@ -472,12 +558,18 @@
|
|
|
472
558
|
(recur (+ waited wait-poll-interval))))))
|
|
473
559
|
|
|
474
560
|
(defn run-worker!
|
|
475
|
-
"Run worker loop
|
|
561
|
+
"Run worker loop with persistent sessions.
|
|
476
562
|
|
|
477
|
-
|
|
563
|
+
Sessions persist across iterations — agents resume where they left off.
|
|
564
|
+
Worktrees persist until COMPLETE_AND_READY_FOR_MERGE triggers review+merge.
|
|
565
|
+
__DONE__ stops the worker entirely (planners only).
|
|
566
|
+
|
|
567
|
+
Tracks per-worker metrics: merges, rejections, errors, review-rounds-total.
|
|
568
|
+
Returns final worker state with metrics attached."
|
|
478
569
|
[worker]
|
|
479
570
|
(tasks/ensure-dirs!)
|
|
480
|
-
(let [{:keys [id iterations]} worker
|
|
571
|
+
(let [{:keys [id iterations]} worker
|
|
572
|
+
project-root (System/getProperty "user.dir")]
|
|
481
573
|
(println (format "[%s] Starting worker (%s:%s%s, %d iterations)"
|
|
482
574
|
id
|
|
483
575
|
(name (:harness worker))
|
|
@@ -489,37 +581,138 @@
|
|
|
489
581
|
(when-not (:can-plan worker)
|
|
490
582
|
(wait-for-tasks! id))
|
|
491
583
|
|
|
584
|
+
;; metrics tracks: {:merges N :rejections N :errors N :review-rounds-total N}
|
|
492
585
|
(loop [iter 1
|
|
493
586
|
completed 0
|
|
494
|
-
consec-errors 0
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
(
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
587
|
+
consec-errors 0
|
|
588
|
+
metrics {:merges 0 :rejections 0 :errors 0 :review-rounds-total 0}
|
|
589
|
+
session-id nil ;; persistent session-id (nil = start fresh)
|
|
590
|
+
wt-state nil] ;; {:dir :branch :path} or nil
|
|
591
|
+
(let [finish (fn [status]
|
|
592
|
+
(assoc worker :completed completed :status status
|
|
593
|
+
:merges (:merges metrics)
|
|
594
|
+
:rejections (:rejections metrics)
|
|
595
|
+
:errors (:errors metrics)
|
|
596
|
+
:review-rounds-total (:review-rounds-total metrics)))]
|
|
597
|
+
(if (> iter iterations)
|
|
598
|
+
(do
|
|
599
|
+
;; Cleanup any lingering worktree
|
|
600
|
+
(when wt-state
|
|
601
|
+
(cleanup-worktree! project-root (:dir wt-state) (:branch wt-state)))
|
|
602
|
+
(println (format "[%s] Completed %d iterations (%d merges, %d rejections, %d errors)"
|
|
603
|
+
id completed (:merges metrics) (:rejections metrics) (:errors metrics)))
|
|
604
|
+
(finish :exhausted))
|
|
605
|
+
|
|
606
|
+
;; Ensure worktree exists (create fresh if nil, reuse if persisted)
|
|
607
|
+
(let [wt-state (try
|
|
608
|
+
(or wt-state (create-iteration-worktree! project-root id iter))
|
|
609
|
+
(catch Exception e
|
|
610
|
+
(println (format "[%s] Worktree creation failed: %s" id (.getMessage e)))
|
|
611
|
+
nil))]
|
|
612
|
+
(if (nil? wt-state)
|
|
613
|
+
;; Worktree creation failed — count as error
|
|
614
|
+
(let [errors (inc consec-errors)
|
|
615
|
+
metrics (update metrics :errors inc)]
|
|
616
|
+
(if (>= errors max-consecutive-errors)
|
|
617
|
+
(do
|
|
618
|
+
(println (format "[%s] %d consecutive errors, stopping" id errors))
|
|
619
|
+
(finish :error))
|
|
620
|
+
(recur (inc iter) completed errors metrics nil nil)))
|
|
621
|
+
|
|
622
|
+
;; Worktree ready — run agent
|
|
623
|
+
(let [resume? (some? session-id)
|
|
624
|
+
_ (println (format "[%s] %s iteration %d/%d"
|
|
625
|
+
id (if resume? "Resuming" "Starting") iter iterations))
|
|
626
|
+
context (build-context)
|
|
627
|
+
{:keys [output exit done? merge?] :as agent-result}
|
|
628
|
+
(run-agent! worker (:path wt-state) context session-id resume?)
|
|
629
|
+
new-session-id (:session-id agent-result)]
|
|
630
|
+
|
|
631
|
+
(cond
|
|
632
|
+
;; Agent errored — cleanup, reset session
|
|
633
|
+
(not (zero? exit))
|
|
634
|
+
(let [errors (inc consec-errors)
|
|
635
|
+
metrics (update metrics :errors inc)]
|
|
636
|
+
(println (format "[%s] Agent error (exit %d): %s"
|
|
637
|
+
id exit (subs (or output "") 0 (min 200 (count (or output ""))))))
|
|
638
|
+
(cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
|
|
639
|
+
(if (>= errors max-consecutive-errors)
|
|
640
|
+
(do
|
|
641
|
+
(println (format "[%s] %d consecutive errors, stopping" id errors))
|
|
642
|
+
(finish :error))
|
|
643
|
+
(recur (inc iter) completed errors metrics nil nil)))
|
|
644
|
+
|
|
645
|
+
;; COMPLETE_AND_READY_FOR_MERGE — review, merge, reset session
|
|
646
|
+
merge?
|
|
647
|
+
(if (worktree-has-changes? (:path wt-state))
|
|
648
|
+
(if (task-only-diff? (:path wt-state))
|
|
649
|
+
;; Task-only changes — skip review, auto-merge
|
|
650
|
+
(do
|
|
651
|
+
(println (format "[%s] Task-only diff, auto-merging" id))
|
|
652
|
+
(let [merged? (merge-to-main! (:path wt-state) (:branch wt-state) id project-root 0)
|
|
653
|
+
metrics (if merged? (update metrics :merges inc) metrics)]
|
|
654
|
+
(println (format "[%s] Iteration %d/%d complete" id iter iterations))
|
|
655
|
+
(cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
|
|
656
|
+
(if (and done? (:can-plan worker))
|
|
657
|
+
(do
|
|
658
|
+
(println (format "[%s] Worker done after merge" id))
|
|
659
|
+
(assoc worker :completed (inc completed) :status :done
|
|
660
|
+
:merges (:merges metrics)
|
|
661
|
+
:rejections (:rejections metrics)
|
|
662
|
+
:errors (:errors metrics)
|
|
663
|
+
:review-rounds-total (:review-rounds-total metrics)))
|
|
664
|
+
(recur (inc iter) (inc completed) 0 metrics nil nil))))
|
|
665
|
+
;; Code changes — full review loop
|
|
666
|
+
(let [{:keys [approved? attempts]} (review-loop! worker (:path wt-state) id iter)
|
|
667
|
+
metrics (-> metrics
|
|
668
|
+
(update :review-rounds-total + (or attempts 0))
|
|
669
|
+
(update (if approved? :merges :rejections) inc))]
|
|
670
|
+
(if approved?
|
|
671
|
+
(do
|
|
672
|
+
(merge-to-main! (:path wt-state) (:branch wt-state) id project-root (or attempts 0))
|
|
673
|
+
(println (format "[%s] Iteration %d/%d complete" id iter iterations))
|
|
674
|
+
(cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
|
|
675
|
+
;; If also __DONE__, stop after merge
|
|
676
|
+
(if (and done? (:can-plan worker))
|
|
677
|
+
(do
|
|
678
|
+
(println (format "[%s] Worker done after merge" id))
|
|
679
|
+
(assoc worker :completed (inc completed) :status :done
|
|
680
|
+
:merges (:merges metrics)
|
|
681
|
+
:rejections (:rejections metrics)
|
|
682
|
+
:errors (:errors metrics)
|
|
683
|
+
:review-rounds-total (:review-rounds-total metrics)))
|
|
684
|
+
(recur (inc iter) (inc completed) 0 metrics nil nil)))
|
|
685
|
+
(do
|
|
686
|
+
(println (format "[%s] Iteration %d/%d rejected" id iter iterations))
|
|
687
|
+
(cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
|
|
688
|
+
(recur (inc iter) completed 0 metrics nil nil)))))
|
|
689
|
+
(do
|
|
690
|
+
(println (format "[%s] Merge signaled but no changes, skipping" id))
|
|
691
|
+
(cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
|
|
692
|
+
(recur (inc iter) completed 0 metrics nil nil)))
|
|
693
|
+
|
|
694
|
+
;; __DONE__ without merge — only honor for planners
|
|
695
|
+
(and done? (:can-plan worker))
|
|
696
|
+
(do
|
|
697
|
+
(println (format "[%s] Received __DONE__ signal" id))
|
|
698
|
+
(cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
|
|
699
|
+
(println (format "[%s] Worker done after %d/%d iterations" id iter iterations))
|
|
700
|
+
(finish :done))
|
|
701
|
+
|
|
702
|
+
;; __DONE__ from executor — ignore signal, but reset session since
|
|
703
|
+
;; the agent process exited. Resuming a dead session causes exit 1
|
|
704
|
+
;; which cascades into consecutive errors and premature stopping.
|
|
705
|
+
(and done? (not (:can-plan worker)))
|
|
706
|
+
(do
|
|
707
|
+
(println (format "[%s] Ignoring __DONE__ (executor), resetting session" id))
|
|
708
|
+
(cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
|
|
709
|
+
(recur (inc iter) completed 0 metrics nil nil))
|
|
710
|
+
|
|
711
|
+
;; No signal — agent still working, resume next iteration
|
|
712
|
+
:else
|
|
713
|
+
(do
|
|
714
|
+
(println (format "[%s] Working... (will resume)" id))
|
|
715
|
+
(recur (inc iter) completed 0 metrics new-session-id wt-state)))))))))))
|
|
523
716
|
|
|
524
717
|
;; =============================================================================
|
|
525
718
|
;; Multi-Worker Execution
|
|
@@ -527,6 +720,7 @@
|
|
|
527
720
|
|
|
528
721
|
(defn run-workers!
|
|
529
722
|
"Run multiple workers in parallel.
|
|
723
|
+
Writes swarm summary to runs/{swarm-id}/summary.edn on completion.
|
|
530
724
|
|
|
531
725
|
Arguments:
|
|
532
726
|
workers - seq of worker configs
|
|
@@ -534,21 +728,106 @@
|
|
|
534
728
|
Returns seq of final worker states."
|
|
535
729
|
[workers]
|
|
536
730
|
(tasks/ensure-dirs!)
|
|
537
|
-
(
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
(
|
|
542
|
-
(
|
|
543
|
-
(
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
(
|
|
549
|
-
|
|
550
|
-
(
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
731
|
+
(let [swarm-id (-> workers first :swarm-id)]
|
|
732
|
+
(println (format "Launching %d workers..." (count workers)))
|
|
733
|
+
|
|
734
|
+
(let [futures (doall
|
|
735
|
+
(map-indexed
|
|
736
|
+
(fn [idx worker]
|
|
737
|
+
(let [worker (assoc worker :id (or (:id worker) (str "w" idx)))]
|
|
738
|
+
(future (run-worker! worker))))
|
|
739
|
+
workers))]
|
|
740
|
+
|
|
741
|
+
(println "All workers launched. Waiting for completion...")
|
|
742
|
+
(let [results (mapv deref futures)]
|
|
743
|
+
(println "\nAll workers complete.")
|
|
744
|
+
(doseq [w results]
|
|
745
|
+
(println (format " [%s] %s - %d completed, %d merges, %d rejections, %d errors, %d review rounds"
|
|
746
|
+
(:id w)
|
|
747
|
+
(name (:status w))
|
|
748
|
+
(:completed w)
|
|
749
|
+
(or (:merges w) 0)
|
|
750
|
+
(or (:rejections w) 0)
|
|
751
|
+
(or (:errors w) 0)
|
|
752
|
+
(or (:review-rounds-total w) 0))))
|
|
753
|
+
|
|
754
|
+
;; Write swarm summary to disk
|
|
755
|
+
(when swarm-id
|
|
756
|
+
(runs/write-summary! swarm-id results)
|
|
757
|
+
(println (format "\nSwarm summary written to runs/%s/summary.edn" swarm-id)))
|
|
758
|
+
|
|
759
|
+
results))))
|
|
760
|
+
|
|
761
|
+
;; =============================================================================
|
|
762
|
+
;; Planner — first-class config concept, NOT a worker
|
|
763
|
+
;; =============================================================================
|
|
764
|
+
;; The planner creates task EDN files in tasks/pending/.
|
|
765
|
+
;; It runs in the project root (no worktree), has no review/merge cycle,
|
|
766
|
+
;; and respects max_pending backpressure to avoid flooding the queue.
|
|
767
|
+
|
|
768
|
+
(defn run-planner!
|
|
769
|
+
"Run planner agent to create tasks. No worktree, no review, no merge.
|
|
770
|
+
Runs in project root. Respects max_pending cap.
|
|
771
|
+
Returns {:tasks-created N}"
|
|
772
|
+
[{:keys [harness model prompts max-pending swarm-id]}]
|
|
773
|
+
(tasks/ensure-dirs!)
|
|
774
|
+
(let [project-root (System/getProperty "user.dir")
|
|
775
|
+
pending-before (tasks/pending-count)
|
|
776
|
+
max-pending (or max-pending 10)]
|
|
777
|
+
;; Backpressure: skip if queue is full
|
|
778
|
+
(if (>= pending-before max-pending)
|
|
779
|
+
(do
|
|
780
|
+
(println (format "[planner] Skipping — %d pending tasks (max: %d)" pending-before max-pending))
|
|
781
|
+
{:tasks-created 0})
|
|
782
|
+
;; Run agent
|
|
783
|
+
(let [context (build-context)
|
|
784
|
+
prompt-text (str (when (seq prompts)
|
|
785
|
+
(->> prompts
|
|
786
|
+
(map load-prompt)
|
|
787
|
+
(remove nil?)
|
|
788
|
+
(str/join "\n\n")))
|
|
789
|
+
"\n\nTask Status: " (:task_status context) "\n"
|
|
790
|
+
"Pending: " (:pending_tasks context) "\n\n"
|
|
791
|
+
"Create tasks in tasks/pending/ as .edn files.\n"
|
|
792
|
+
"Maximum " (- max-pending pending-before) " new tasks.\n"
|
|
793
|
+
"Signal __DONE__ when finished planning.")
|
|
794
|
+
swarm-id* (or swarm-id "unknown")
|
|
795
|
+
tagged-prompt (str "[oompa:" swarm-id* ":planner] " prompt-text)
|
|
796
|
+
abs-root (.getAbsolutePath (io/file project-root))
|
|
797
|
+
opencode-attach (opencode-attach-url)
|
|
798
|
+
|
|
799
|
+
cmd (case harness
|
|
800
|
+
:codex (cond-> [(resolve-binary! "codex") "exec"
|
|
801
|
+
"--dangerously-bypass-approvals-and-sandbox"
|
|
802
|
+
"--skip-git-repo-check"
|
|
803
|
+
"-C" abs-root]
|
|
804
|
+
model (into ["--model" model])
|
|
805
|
+
true (conj "--" tagged-prompt))
|
|
806
|
+
:claude (cond-> [(resolve-binary! "claude") "-p" "--dangerously-skip-permissions"]
|
|
807
|
+
model (into ["--model" model]))
|
|
808
|
+
:opencode (cond-> [(resolve-binary! "opencode") "run"]
|
|
809
|
+
model (into ["-m" model])
|
|
810
|
+
opencode-attach (into ["--attach" opencode-attach])
|
|
811
|
+
true (conj tagged-prompt)))
|
|
812
|
+
|
|
813
|
+
_ (println (format "[planner] Running (%s:%s, max_pending: %d, current: %d)"
|
|
814
|
+
(name harness) (or model "default") max-pending pending-before))
|
|
815
|
+
|
|
816
|
+
result (try
|
|
817
|
+
(if (= harness :claude)
|
|
818
|
+
(process/sh cmd {:dir abs-root :in tagged-prompt :out :string :err :string})
|
|
819
|
+
(process/sh cmd {:dir abs-root :out :string :err :string}))
|
|
820
|
+
(catch Exception e
|
|
821
|
+
(println (format "[planner] Agent exception: %s" (.getMessage e)))
|
|
822
|
+
{:exit -1 :out "" :err (.getMessage e)}))
|
|
823
|
+
|
|
824
|
+
;; Commit any new task files
|
|
825
|
+
_ (process/sh ["git" "add" "tasks/pending/"] {:dir abs-root})
|
|
826
|
+
_ (process/sh ["git" "commit" "-m" "Planner: add tasks"]
|
|
827
|
+
{:dir abs-root :out :string :err :string})
|
|
828
|
+
|
|
829
|
+
pending-after (tasks/pending-count)
|
|
830
|
+
created (- pending-after pending-before)]
|
|
831
|
+
|
|
832
|
+
(println (format "[planner] Done. Created %d tasks (pending: %d)" created pending-after))
|
|
833
|
+
{:tasks-created created}))))
|