@nbardy/oompa 0.1.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.
@@ -0,0 +1,427 @@
1
+ (ns agentnet.worker
2
+ "Self-directed worker execution.
3
+
4
+ Workers:
5
+ 1. Claim tasks from tasks/pending/ (mv → current/)
6
+ 2. Execute task in worktree
7
+ 3. Commit changes
8
+ 4. Reviewer checks work (if configured)
9
+ 5. If approved → merge to main, complete task
10
+ 6. If rejected → fix & retry → back to reviewer
11
+ 7. Can create new tasks in pending/
12
+ 8. Exit on __DONE__ signal
13
+
14
+ No separate orchestrator - workers self-organize."
15
+ (:require [agentnet.tasks :as tasks]
16
+ [agentnet.agent :as agent]
17
+ [agentnet.worktree :as worktree]
18
+ [babashka.process :as process]
19
+ [clojure.java.io :as io]
20
+ [clojure.string :as str]))
21
+
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
+ ;; =============================================================================
93
+ ;; Worker State
94
+ ;; =============================================================================
95
+
96
+ (defn create-worker
97
+ "Create a worker config"
98
+ [{:keys [id swarm-id harness model iterations custom-prompt review-harness review-model]}]
99
+ {:id id
100
+ :swarm-id swarm-id
101
+ :harness (or harness :codex)
102
+ :model model
103
+ :iterations (or iterations 10)
104
+ :custom-prompt custom-prompt
105
+ :review-harness review-harness
106
+ :review-model review-model
107
+ :completed 0
108
+ :status :idle})
109
+
110
+ ;; =============================================================================
111
+ ;; Task Execution
112
+ ;; =============================================================================
113
+
114
+ (def ^:private max-review-retries 3)
115
+
116
+ (defn- build-context
117
+ "Build context for agent prompts"
118
+ []
119
+ (let [pending (tasks/list-pending)
120
+ current (tasks/list-current)
121
+ complete (tasks/list-complete)]
122
+ {:pending_count (count pending)
123
+ :current_count (count current)
124
+ :complete_count (count complete)
125
+ :pending_tasks (str/join "\n" (map #(str "- " (:id %) ": " (:summary %)) pending))
126
+ :task_status (format "Pending: %d, In Progress: %d, Complete: %d"
127
+ (count pending) (count current) (count complete))}))
128
+
129
+ (defn- run-agent!
130
+ "Run agent with prompt, return {:output string, :done? bool, :exit int}"
131
+ [{:keys [id swarm-id harness model custom-prompt]} worktree-path context]
132
+ (let [;; Load prompt (check config/prompts/ as default)
133
+ prompt-content (or (agent/load-custom-prompt custom-prompt)
134
+ (agent/load-custom-prompt "config/prompts/worker.md")
135
+ "Goal: Match spec.md\nProcess: Create/claim tasks in tasks/{pending,current,complete}/*.edn\nMethod: Isolate changes to your worktree, commit and merge when complete")
136
+
137
+ ;; Inject worktree and context
138
+ full-prompt (str "Worktree: " worktree-path "\n"
139
+ "Task Status: " (:task_status context) "\n\n"
140
+ prompt-content)
141
+ session-id (str/lower-case (str (java.util.UUID/randomUUID)))
142
+ swarm-id* (or swarm-id "unknown")
143
+ tagged-prompt (str "[oompa:" swarm-id* ":" id "] " full-prompt)
144
+ abs-worktree (.getAbsolutePath (io/file worktree-path))
145
+
146
+ ;; Build command
147
+ cmd (case harness
148
+ :codex (cond-> ["codex" "exec" "--full-auto" "--skip-git-repo-check"
149
+ "-C" worktree-path "--sandbox" "workspace-write"]
150
+ model (into ["--model" model])
151
+ true (conj "--" full-prompt))
152
+ :claude (cond-> ["claude" "-p" "--dangerously-skip-permissions"
153
+ "--session-id" session-id]
154
+ model (into ["--model" model])))
155
+
156
+ _ (when (= harness :codex)
157
+ (persist-message! id session-id abs-worktree "user" tagged-prompt))
158
+
159
+ ;; Run agent
160
+ result (try
161
+ (if (= harness :claude)
162
+ ;; Claude reads from stdin
163
+ (process/sh cmd {:in tagged-prompt :out :string :err :string})
164
+ ;; Codex takes prompt as arg
165
+ (process/sh cmd {:out :string :err :string}))
166
+ (catch Exception e
167
+ {:exit -1 :out "" :err (.getMessage e)}))]
168
+
169
+ (when (= harness :codex)
170
+ (persist-message! id session-id abs-worktree "assistant" (safe-assistant-content result)))
171
+
172
+ {:output (:out result)
173
+ :exit (:exit result)
174
+ :done? (agent/done-signal? (:out result))}))
175
+
176
+ (defn- run-reviewer!
177
+ "Run reviewer on worktree changes.
178
+ Returns {:verdict :approved|:needs-changes|:rejected, :comments [...]}"
179
+ [{:keys [review-harness review-model]} worktree-path]
180
+ (let [;; Get diff for context
181
+ diff-result (process/sh ["git" "diff" "main" "--stat"]
182
+ {:dir worktree-path :out :string :err :string})
183
+ diff-summary (:out diff-result)
184
+
185
+ ;; Build review prompt
186
+ review-prompt (str "Review the changes in this worktree.\n\n"
187
+ "Diff summary:\n" diff-summary "\n\n"
188
+ "Check for:\n"
189
+ "- Code correctness\n"
190
+ "- Matches the intended task\n"
191
+ "- No obvious bugs or issues\n\n"
192
+ "Respond with:\n"
193
+ "- APPROVED if changes are good\n"
194
+ "- NEEDS_CHANGES with bullet points of issues\n"
195
+ "- REJECTED if fundamentally wrong")
196
+
197
+ ;; Build command
198
+ cmd (case review-harness
199
+ :codex (cond-> ["codex" "exec" "--full-auto" "--skip-git-repo-check"
200
+ "-C" worktree-path "--sandbox" "workspace-write"]
201
+ review-model (into ["--model" review-model])
202
+ true (conj "--" review-prompt))
203
+ :claude (cond-> ["claude" "-p" "--dangerously-skip-permissions"]
204
+ review-model (into ["--model" review-model])))
205
+
206
+ ;; Run reviewer
207
+ result (try
208
+ (if (= review-harness :claude)
209
+ (process/sh cmd {:in review-prompt :out :string :err :string})
210
+ (process/sh cmd {:out :string :err :string}))
211
+ (catch Exception e
212
+ {:exit -1 :out "" :err (.getMessage e)}))
213
+
214
+ output (:out result)]
215
+
216
+ {:verdict (cond
217
+ (re-find #"(?i)\bAPPROVED\b" output) :approved
218
+ (re-find #"(?i)\bREJECTED\b" output) :rejected
219
+ :else :needs-changes)
220
+ :comments (when (not= (:exit result) 0)
221
+ [(:err result)])
222
+ :output output}))
223
+
224
+ (defn- run-fix!
225
+ "Ask worker to fix issues based on reviewer feedback.
226
+ Returns {:output string, :exit int}"
227
+ [{:keys [harness model]} worktree-path feedback]
228
+ (let [fix-prompt (str "The reviewer found issues with your changes:\n\n"
229
+ feedback "\n\n"
230
+ "Please fix these issues in the worktree.")
231
+
232
+ cmd (case harness
233
+ :codex (cond-> ["codex" "exec" "--full-auto" "--skip-git-repo-check"
234
+ "-C" worktree-path "--sandbox" "workspace-write"]
235
+ model (into ["--model" model])
236
+ true (conj "--" fix-prompt))
237
+ :claude (cond-> ["claude" "-p" "--dangerously-skip-permissions"]
238
+ model (into ["--model" model])))
239
+
240
+ result (try
241
+ (if (= harness :claude)
242
+ (process/sh cmd {:in fix-prompt :out :string :err :string})
243
+ (process/sh cmd {:out :string :err :string}))
244
+ (catch Exception e
245
+ {:exit -1 :out "" :err (.getMessage e)}))]
246
+
247
+ {:output (:out result)
248
+ :exit (:exit result)}))
249
+
250
+ (defn- merge-to-main!
251
+ "Merge worktree changes to main branch"
252
+ [wt-path wt-id worker-id]
253
+ (println (format "[%s] Merging changes to main" worker-id))
254
+ (let [;; Commit in worktree if needed
255
+ _ (process/sh ["git" "add" "-A"] {:dir wt-path})
256
+ _ (process/sh ["git" "commit" "-m" (str "Work from " wt-id) "--allow-empty"]
257
+ {:dir wt-path})
258
+ ;; Checkout main and merge
259
+ checkout-result (process/sh ["git" "checkout" "main"]
260
+ {:out :string :err :string})
261
+ merge-result (when (zero? (:exit checkout-result))
262
+ (process/sh ["git" "merge" wt-id "--no-edit"]
263
+ {:out :string :err :string}))]
264
+ (and (zero? (:exit checkout-result))
265
+ (zero? (:exit merge-result)))))
266
+
267
+ (defn- review-loop!
268
+ "Run review loop: reviewer checks → if issues, fix & retry → back to reviewer.
269
+ Returns {:approved? bool, :attempts int}"
270
+ [worker wt-path worker-id]
271
+ (if-not (and (:review-harness worker) (:review-model worker))
272
+ ;; No reviewer configured, auto-approve
273
+ {:approved? true :attempts 0}
274
+
275
+ ;; Run review loop
276
+ (loop [attempt 1]
277
+ (println (format "[%s] Review attempt %d/%d" worker-id attempt max-review-retries))
278
+ (let [{:keys [verdict output]} (run-reviewer! worker wt-path)]
279
+ (case verdict
280
+ :approved
281
+ (do
282
+ (println (format "[%s] Reviewer APPROVED" worker-id))
283
+ {:approved? true :attempts attempt})
284
+
285
+ :rejected
286
+ (do
287
+ (println (format "[%s] Reviewer REJECTED" worker-id))
288
+ {:approved? false :attempts attempt})
289
+
290
+ ;; :needs-changes
291
+ (if (>= attempt max-review-retries)
292
+ (do
293
+ (println (format "[%s] Max review retries reached" worker-id))
294
+ {:approved? false :attempts attempt})
295
+ (do
296
+ (println (format "[%s] Reviewer requested changes, fixing..." worker-id))
297
+ (run-fix! worker wt-path output)
298
+ (recur (inc attempt)))))))))
299
+
300
+ (defn execute-iteration!
301
+ "Execute one iteration of work.
302
+
303
+ Flow:
304
+ 1. Create worktree
305
+ 2. Run agent
306
+ 3. If reviewer configured: run review loop (fix → retry → reviewer)
307
+ 4. If approved: merge to main
308
+ 5. Cleanup worktree
309
+
310
+ Returns {:status :done|:continue|:error, :task task-or-nil}"
311
+ [worker iteration total-iterations]
312
+ (let [worker-id (:id worker)
313
+ wt-id (format ".w%s-i%d" worker-id iteration)
314
+
315
+ ;; Create worktree
316
+ _ (println (format "[%s] Starting iteration %d/%d" worker-id iteration total-iterations))
317
+ wt-path (str (System/getProperty "user.dir") "/" wt-id)]
318
+
319
+ (try
320
+ ;; Setup worktree
321
+ (process/sh ["git" "worktree" "add" wt-id "-b" wt-id])
322
+
323
+ ;; Build context
324
+ (let [context (build-context)
325
+
326
+ ;; Run agent
327
+ {:keys [output exit done?]} (run-agent! worker wt-path context)]
328
+
329
+ (cond
330
+ ;; Agent signaled done
331
+ done?
332
+ (do
333
+ (println (format "[%s] Received __DONE__ signal" worker-id))
334
+ {:status :done})
335
+
336
+ ;; Agent errored
337
+ (not (zero? exit))
338
+ (do
339
+ (println (format "[%s] Agent error (exit %d)" worker-id exit))
340
+ {:status :error :exit exit})
341
+
342
+ ;; Success - run review loop before merge
343
+ :else
344
+ (let [{:keys [approved?]} (review-loop! worker wt-path worker-id)]
345
+ (if approved?
346
+ (do
347
+ (merge-to-main! wt-path wt-id worker-id)
348
+ (println (format "[%s] Iteration %d/%d complete" worker-id iteration total-iterations))
349
+ {:status :continue})
350
+ (do
351
+ (println (format "[%s] Iteration %d/%d rejected, discarding" worker-id iteration total-iterations))
352
+ {:status :continue})))))
353
+
354
+ (finally
355
+ ;; Cleanup worktree
356
+ (process/sh ["git" "worktree" "remove" wt-id "--force"])))))
357
+
358
+ ;; =============================================================================
359
+ ;; Worker Loop
360
+ ;; =============================================================================
361
+
362
+ (defn run-worker!
363
+ "Run worker loop until done or iterations exhausted.
364
+
365
+ Returns final worker state."
366
+ [worker]
367
+ (tasks/ensure-dirs!)
368
+ (let [{:keys [id iterations]} worker]
369
+ (println (format "[%s] Starting worker (%s:%s, %d iterations)"
370
+ id
371
+ (name (:harness worker))
372
+ (or (:model worker) "default")
373
+ iterations))
374
+
375
+ (loop [iter 1
376
+ completed 0]
377
+ (if (> iter iterations)
378
+ (do
379
+ (println (format "[%s] Completed %d iterations" id completed))
380
+ (assoc worker :completed completed :status :exhausted))
381
+
382
+ (let [{:keys [status]} (execute-iteration! worker iter iterations)]
383
+ (case status
384
+ :done
385
+ (do
386
+ (println (format "[%s] Worker done after %d/%d iterations" id iter iterations))
387
+ (assoc worker :completed iter :status :done))
388
+
389
+ :error
390
+ (do
391
+ (println (format "[%s] Worker error at iteration %d/%d, continuing..." id iter iterations))
392
+ (recur (inc iter) completed))
393
+
394
+ :continue
395
+ (recur (inc iter) (inc completed))))))))
396
+
397
+ ;; =============================================================================
398
+ ;; Multi-Worker Execution
399
+ ;; =============================================================================
400
+
401
+ (defn run-workers!
402
+ "Run multiple workers in parallel.
403
+
404
+ Arguments:
405
+ workers - seq of worker configs
406
+
407
+ Returns seq of final worker states."
408
+ [workers]
409
+ (tasks/ensure-dirs!)
410
+ (println (format "Launching %d workers..." (count workers)))
411
+
412
+ (let [futures (doall
413
+ (map-indexed
414
+ (fn [idx worker]
415
+ (let [worker (assoc worker :id (or (:id worker) (str "w" idx)))]
416
+ (future (run-worker! worker))))
417
+ workers))]
418
+
419
+ (println "All workers launched. Waiting for completion...")
420
+ (let [results (mapv deref futures)]
421
+ (println "\nAll workers complete.")
422
+ (doseq [w results]
423
+ (println (format " [%s] %s - %d iterations"
424
+ (:id w)
425
+ (name (:status w))
426
+ (:completed w))))
427
+ results)))