@nbardy/oompa 0.6.0 → 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.
@@ -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
- [{:keys [id swarm-id harness model iterations prompts can-plan reasoning review-harness review-model]}]
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,13 +106,53 @@
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
148
  "Run agent with prompt, return {:output string, :done? bool, :merge? bool, :exit int, :session-id string}.
169
- When resume? is true and harness is :claude, uses --resume to continue the existing session
149
+ When resume? is true and harness is :claude/:opencode, continues the existing session
170
150
  with a lighter prompt (just task status + continue instruction)."
171
151
  [{:keys [id swarm-id harness model prompts reasoning]} worktree-path context session-id resume?]
172
- (let [;; Use provided session-id or generate fresh one
173
- session-id (or session-id (str/lower-case (str (java.util.UUID/randomUUID))))
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)))))
174
156
 
175
157
  ;; Build prompt — lighter for resume (agent already has full context)
176
158
  prompt (if resume?
@@ -193,11 +175,14 @@
193
175
  swarm-id* (or swarm-id "unknown")
194
176
  tagged-prompt (str "[oompa:" swarm-id* ":" id "] " prompt)
195
177
  abs-worktree (.getAbsolutePath (io/file worktree-path))
178
+ opencode-attach (opencode-attach-url)
196
179
 
197
- ;; Build command — both harnesses run with cwd=worktree, no sandbox
180
+ ;; Build command — all harnesses run with cwd=worktree, no sandbox
198
181
  ;; so agents can `..` to reach project root for task management
199
182
  ;; Claude: --resume flag continues existing session-id conversation
200
- ;; Codex: no resume support, always fresh (but worktree state persists)
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)
201
186
  cmd (case harness
202
187
  :codex (cond-> [(resolve-binary! "codex") "exec"
203
188
  "--dangerously-bypass-approvals-and-sandbox"
@@ -209,53 +194,82 @@
209
194
  :claude (cond-> [(resolve-binary! "claude") "-p" "--dangerously-skip-permissions"
210
195
  "--session-id" session-id]
211
196
  resume? (conj "--resume")
212
- model (into ["--model" model])))
213
-
214
- _ (when (= harness :codex)
215
- (persist-message! id session-id abs-worktree "user" tagged-prompt))
216
-
217
- ;; Run agent — both run with cwd=worktree
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
218
205
  result (try
219
206
  (if (= harness :claude)
220
207
  (process/sh cmd {:dir abs-worktree :in tagged-prompt :out :string :err :string})
221
208
  (process/sh cmd {:dir abs-worktree :out :string :err :string}))
222
209
  (catch Exception e
223
210
  (println (format "[%s] Agent exception: %s" id (.getMessage e)))
224
- {:exit -1 :out "" :err (.getMessage e)}))]
225
-
226
- (when (= harness :codex)
227
- (persist-message! id session-id abs-worktree "assistant" (safe-assistant-content result)))
228
-
229
- {:output (:out result)
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
230
222
  :exit (:exit result)
231
- :done? (agent/done-signal? (:out result))
232
- :merge? (agent/merge-signal? (:out result))
233
- :session-id session-id}))
223
+ :done? (agent/done-signal? output)
224
+ :merge? (agent/merge-signal? output)
225
+ :session-id session-id'}))
234
226
 
235
227
  (defn- run-reviewer!
236
228
  "Run reviewer on worktree changes.
237
- Returns {:verdict :approved|:needs-changes|:rejected, :comments [...]}"
238
- [{:keys [id swarm-id review-harness review-model]} worktree-path]
239
- (let [;; Get diff for context
240
- diff-result (process/sh ["git" "diff" "main" "--stat"]
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"]
241
235
  {:dir worktree-path :out :string :err :string})
242
- diff-summary (:out diff-result)
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))
243
240
 
244
- ;; Build review prompt (tagged for claude-web-view worker detection)
241
+ ;; Build review prompt use custom prompts if configured, else default
245
242
  swarm-id* (or swarm-id "unknown")
246
- review-prompt (str "[oompa:" swarm-id* ":" id "] "
247
- "Review the changes in this worktree.\n\n"
248
- "Diff summary:\n" diff-summary "\n\n"
249
- "Check for:\n"
250
- "- Code correctness\n"
251
- "- Matches the intended task\n"
252
- "- No obvious bugs or issues\n\n"
253
- "Respond with:\n"
254
- "- APPROVED if changes are good\n"
255
- "- NEEDS_CHANGES with bullet points of issues\n"
256
- "- REJECTED if fundamentally wrong")
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)
257
270
 
258
271
  abs-wt (.getAbsolutePath (io/file worktree-path))
272
+ opencode-attach (opencode-attach-url)
259
273
 
260
274
  ;; Build command — cwd=worktree, no sandbox
261
275
  cmd (case review-harness
@@ -266,7 +280,11 @@
266
280
  review-model (into ["--model" review-model])
267
281
  true (conj "--" review-prompt))
268
282
  :claude (cond-> [(resolve-binary! "claude") "-p" "--dangerously-skip-permissions"]
269
- 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)))
270
288
 
271
289
  ;; Run reviewer — cwd=worktree
272
290
  result (try
@@ -276,27 +294,50 @@
276
294
  (catch Exception e
277
295
  {:exit -1 :out "" :err (.getMessage e)}))
278
296
 
279
- output (:out result)]
280
-
281
- {:verdict (cond
282
- (re-find #"(?i)\bAPPROVED\b" output) :approved
283
- (re-find #"(?i)\bREJECTED\b" output) :rejected
284
- :else :needs-changes)
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
285
316
  :comments (when (not= (:exit result) 0)
286
317
  [(:err result)])
287
318
  :output output}))
288
319
 
289
320
  (defn- run-fix!
290
321
  "Ask worker to fix issues based on reviewer feedback.
322
+ all-feedback: vector of all reviewer outputs so far (accumulated across rounds).
291
323
  Returns {:output string, :exit int}"
292
- [{:keys [id swarm-id harness model]} worktree-path feedback]
324
+ [{:keys [id swarm-id harness model]} worktree-path all-feedback]
293
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)))
294
335
  fix-prompt (str "[oompa:" swarm-id* ":" id "] "
295
- "The reviewer found issues with your changes:\n\n"
296
- feedback "\n\n"
297
- "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.")
298
338
 
299
339
  abs-wt (.getAbsolutePath (io/file worktree-path))
340
+ opencode-attach (opencode-attach-url)
300
341
 
301
342
  cmd (case harness
302
343
  :codex (cond-> [(resolve-binary! "codex") "exec"
@@ -306,7 +347,11 @@
306
347
  model (into ["--model" model])
307
348
  true (conj "--" fix-prompt))
308
349
  :claude (cond-> [(resolve-binary! "claude") "-p" "--dangerously-skip-permissions"]
309
- 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)))
310
355
 
311
356
  result (try
312
357
  (if (= harness :claude)
@@ -319,10 +364,19 @@
319
364
  :exit (:exit result)}))
320
365
 
321
366
  (defn- worktree-has-changes?
322
- "Check if worktree has any uncommitted changes (new/modified/deleted files)."
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)"
323
371
  [wt-path]
324
- (let [result (process/sh ["git" "status" "--porcelain"] {:dir wt-path :out :string :err :string})]
325
- (not (str/blank? (:out result)))))
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))))
326
380
 
327
381
  (defn- create-iteration-worktree!
328
382
  "Create a fresh worktree for an iteration. Returns {:dir :branch :path}.
@@ -347,55 +401,136 @@
347
401
  (process/sh ["git" "worktree" "remove" wt-dir "--force"] {:dir project-root})
348
402
  (process/sh ["git" "branch" "-D" wt-branch] {:dir project-root}))
349
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))))))))))
433
+
350
434
  (defn- merge-to-main!
351
- "Merge worktree changes to main branch"
352
- [wt-path wt-id worker-id project-root]
353
- (println (format "[%s] Merging changes to main" worker-id))
354
- (let [;; Commit in worktree if needed
355
- _ (process/sh ["git" "add" "-A"] {:dir wt-path})
356
- _ (process/sh ["git" "commit" "-m" (str "Work from " wt-id)]
357
- {:dir wt-path})
358
- ;; Checkout main and merge (in project root, not worktree)
359
- checkout-result (process/sh ["git" "checkout" "main"]
360
- {:dir project-root :out :string :err :string})
361
- merge-result (when (zero? (:exit checkout-result))
362
- (process/sh ["git" "merge" wt-id "--no-edit"]
363
- {:dir project-root :out :string :err :string}))]
364
- (and (zero? (:exit checkout-result))
365
- (zero? (:exit merge-result)))))
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))))
366
487
 
367
488
  (defn- review-loop!
368
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.
369
493
  Returns {:approved? bool, :attempts int}"
370
- [worker wt-path worker-id]
494
+ [worker wt-path worker-id iteration]
371
495
  (if-not (and (:review-harness worker) (:review-model worker))
372
496
  ;; No reviewer configured, auto-approve
373
497
  {:approved? true :attempts 0}
374
498
 
375
- ;; Run review loop
376
- (loop [attempt 1]
499
+ ;; Run review loop with accumulated feedback
500
+ (loop [attempt 1
501
+ prev-feedback []]
377
502
  (println (format "[%s] Review attempt %d/%d" worker-id attempt max-review-retries))
378
- (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
+
379
513
  (case verdict
380
514
  :approved
381
515
  (do
382
- (println (format "[%s] Reviewer APPROVED" worker-id))
516
+ (println (format "[%s] Reviewer APPROVED (attempt %d)" worker-id attempt))
383
517
  {:approved? true :attempts attempt})
384
518
 
385
519
  :rejected
386
520
  (do
387
- (println (format "[%s] Reviewer REJECTED" worker-id))
521
+ (println (format "[%s] Reviewer REJECTED (attempt %d)" worker-id attempt))
388
522
  {:approved? false :attempts attempt})
389
523
 
390
524
  ;; :needs-changes
391
- (if (>= attempt max-review-retries)
392
- (do
393
- (println (format "[%s] Max review retries reached" worker-id))
394
- {:approved? false :attempts attempt})
395
- (do
396
- (println (format "[%s] Reviewer requested changes, fixing..." worker-id))
397
- (run-fix! worker wt-path output)
398
- (recur (inc attempt)))))))))
525
+ (let [all-feedback (conj prev-feedback output)]
526
+ (if (>= attempt max-review-retries)
527
+ (do
528
+ (println (format "[%s] Max review retries reached (%d rounds)" worker-id attempt))
529
+ {:approved? false :attempts attempt})
530
+ (do
531
+ (println (format "[%s] Reviewer requested changes, fixing..." worker-id))
532
+ (run-fix! worker wt-path all-feedback)
533
+ (recur (inc attempt) all-feedback)))))))))
399
534
 
400
535
  ;; =============================================================================
401
536
  ;; Worker Loop
@@ -429,7 +564,8 @@
429
564
  Worktrees persist until COMPLETE_AND_READY_FOR_MERGE triggers review+merge.
430
565
  __DONE__ stops the worker entirely (planners only).
431
566
 
432
- Returns final worker state."
567
+ Tracks per-worker metrics: merges, rejections, errors, review-rounds-total.
568
+ Returns final worker state with metrics attached."
433
569
  [worker]
434
570
  (tasks/ensure-dirs!)
435
571
  (let [{:keys [id iterations]} worker
@@ -445,99 +581,138 @@
445
581
  (when-not (:can-plan worker)
446
582
  (wait-for-tasks! id))
447
583
 
584
+ ;; metrics tracks: {:merges N :rejections N :errors N :review-rounds-total N}
448
585
  (loop [iter 1
449
586
  completed 0
450
587
  consec-errors 0
588
+ metrics {:merges 0 :rejections 0 :errors 0 :review-rounds-total 0}
451
589
  session-id nil ;; persistent session-id (nil = start fresh)
452
590
  wt-state nil] ;; {:dir :branch :path} or nil
453
- (if (> iter iterations)
454
- (do
455
- ;; Cleanup any lingering worktree
456
- (when wt-state
457
- (cleanup-worktree! project-root (:dir wt-state) (:branch wt-state)))
458
- (println (format "[%s] Completed %d iterations" id completed))
459
- (assoc worker :completed completed :status :exhausted))
460
-
461
- ;; Ensure worktree exists (create fresh if nil, reuse if persisted)
462
- (let [wt-state (try
463
- (or wt-state (create-iteration-worktree! project-root id iter))
464
- (catch Exception e
465
- (println (format "[%s] Worktree creation failed: %s" id (.getMessage e)))
466
- nil))]
467
- (if (nil? wt-state)
468
- ;; Worktree creation failed count as error
469
- (let [errors (inc consec-errors)]
470
- (if (>= errors max-consecutive-errors)
471
- (do
472
- (println (format "[%s] %d consecutive errors, stopping" id errors))
473
- (assoc worker :completed completed :status :error))
474
- (recur (inc iter) completed errors nil nil)))
475
-
476
- ;; Worktree ready — run agent
477
- (let [resume? (some? session-id)
478
- _ (println (format "[%s] %s iteration %d/%d"
479
- id (if resume? "Resuming" "Starting") iter iterations))
480
- context (build-context)
481
- {:keys [output exit done? merge?] :as agent-result}
482
- (run-agent! worker (:path wt-state) context session-id resume?)
483
- new-session-id (:session-id agent-result)]
484
-
485
- (cond
486
- ;; Agent errored cleanup, reset session
487
- (not (zero? exit))
488
- (let [errors (inc consec-errors)]
489
- (println (format "[%s] Agent error (exit %d): %s"
490
- id exit (subs (or output "") 0 (min 200 (count (or output ""))))))
491
- (cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
492
- (if (>= errors max-consecutive-errors)
493
- (do
494
- (println (format "[%s] %d consecutive errors, stopping" id errors))
495
- (assoc worker :completed completed :status :error))
496
- (recur (inc iter) completed errors nil nil)))
497
-
498
- ;; COMPLETE_AND_READY_FOR_MERGE review, merge, reset session
499
- merge?
500
- (if (worktree-has-changes? (:path wt-state))
501
- (let [{:keys [approved?]} (review-loop! worker (:path wt-state) id)]
502
- (if approved?
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)
503
640
  (do
504
- (merge-to-main! (:path wt-state) (:branch wt-state) id project-root)
505
- (println (format "[%s] Iteration %d/%d complete" id iter iterations))
506
- (cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
507
- ;; If also __DONE__, stop after merge
508
- (if (and done? (:can-plan worker))
509
- (do
510
- (println (format "[%s] Worker done after merge" id))
511
- (assoc worker :completed (inc completed) :status :done))
512
- (recur (inc iter) (inc completed) 0 nil nil)))
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
513
650
  (do
514
- (println (format "[%s] Iteration %d/%d rejected" id iter iterations))
515
- (cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
516
- (recur (inc iter) completed 0 nil nil))))
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)))
517
706
  (do
518
- (println (format "[%s] Merge signaled but no changes, skipping" id))
707
+ (println (format "[%s] Ignoring __DONE__ (executor), resetting session" id))
519
708
  (cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
520
- (recur (inc iter) completed 0 nil nil)))
521
-
522
- ;; __DONE__ without merge only honor for planners
523
- (and done? (:can-plan worker))
524
- (do
525
- (println (format "[%s] Received __DONE__ signal" id))
526
- (cleanup-worktree! project-root (:dir wt-state) (:branch wt-state))
527
- (println (format "[%s] Worker done after %d/%d iterations" id iter iterations))
528
- (assoc worker :completed completed :status :done))
529
-
530
- ;; __DONE__ from executor — ignore, keep working
531
- (and done? (not (:can-plan worker)))
532
- (do
533
- (println (format "[%s] Ignoring __DONE__ (executor)" id))
534
- (recur (inc iter) completed consec-errors new-session-id wt-state))
535
-
536
- ;; No signal — agent still working, resume next iteration
537
- :else
538
- (do
539
- (println (format "[%s] Working... (will resume)" id))
540
- (recur (inc iter) completed 0 new-session-id wt-state))))))))))
709
+ (recur (inc iter) completed 0 metrics nil nil))
710
+
711
+ ;; No signalagent 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)))))))))))
541
716
 
542
717
  ;; =============================================================================
543
718
  ;; Multi-Worker Execution
@@ -545,6 +720,7 @@
545
720
 
546
721
  (defn run-workers!
547
722
  "Run multiple workers in parallel.
723
+ Writes swarm summary to runs/{swarm-id}/summary.edn on completion.
548
724
 
549
725
  Arguments:
550
726
  workers - seq of worker configs
@@ -552,21 +728,106 @@
552
728
  Returns seq of final worker states."
553
729
  [workers]
554
730
  (tasks/ensure-dirs!)
555
- (println (format "Launching %d workers..." (count workers)))
556
-
557
- (let [futures (doall
558
- (map-indexed
559
- (fn [idx worker]
560
- (let [worker (assoc worker :id (or (:id worker) (str "w" idx)))]
561
- (future (run-worker! worker))))
562
- workers))]
563
-
564
- (println "All workers launched. Waiting for completion...")
565
- (let [results (mapv deref futures)]
566
- (println "\nAll workers complete.")
567
- (doseq [w results]
568
- (println (format " [%s] %s - %d iterations"
569
- (:id w)
570
- (name (:status w))
571
- (:completed w))))
572
- results)))
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}))))