@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,367 @@
1
+ (ns agentnet.orchestrator
2
+ "Main orchestration loop coordinating agents, worktrees, and merges.
3
+
4
+ The orchestrator manages the full lifecycle:
5
+ 1. Initialize worktree pool
6
+ 2. Load task queue
7
+ 3. Dispatch tasks to workers
8
+ 4. Run propose/review loops
9
+ 5. Merge approved changes
10
+ 6. Log results
11
+
12
+ Design:
13
+ - Workers are lightweight (just state, not threads)
14
+ - core.async for parallelism
15
+ - Each worker owns one worktree
16
+ - Tasks flow: queue -> worker -> review -> merge -> done
17
+
18
+ Concurrency Model:
19
+ - N workers, N worktrees
20
+ - Each worker processes one task at a time
21
+ - Workers can run in parallel (different worktrees)
22
+ - Merges are serialized (one at a time to main)"
23
+ (:require [agentnet.schema :as schema]
24
+ [agentnet.core :as core]
25
+ [agentnet.agent :as agent]
26
+ [agentnet.worktree :as worktree]
27
+ [agentnet.review :as review]
28
+ [agentnet.merge :as merge]
29
+ [clojure.core.async :as async]
30
+ [clojure.edn :as edn]
31
+ [clojure.java.io :as io]
32
+ [cheshire.core :as json]))
33
+
34
+ ;; =============================================================================
35
+ ;; Function Specs
36
+ ;; =============================================================================
37
+
38
+ ;; create-orchestrator : OrchestratorConfig -> OrchestratorState
39
+ ;; Initialize orchestrator with workers and worktree pool
40
+
41
+ ;; run! : OrchestratorState -> OrchestratorState
42
+ ;; Process all tasks to completion
43
+
44
+ ;; process-task! : Worker, Task, Context -> TaskResult
45
+ ;; Full lifecycle for one task (propose, review, merge)
46
+
47
+ ;; shutdown! : OrchestratorState -> nil
48
+ ;; Clean up resources
49
+
50
+ ;; =============================================================================
51
+ ;; Constants
52
+ ;; =============================================================================
53
+
54
+ (def ^:const TASKS_PATH "config/tasks.edn")
55
+ (def ^:const POLICY_PATH "config/policy.edn")
56
+ (def ^:const RUNS_DIR "runs")
57
+
58
+ ;; =============================================================================
59
+ ;; State Management
60
+ ;; =============================================================================
61
+
62
+ (defn- now-ms []
63
+ (System/currentTimeMillis))
64
+
65
+ (defn- read-edn [path]
66
+ (let [f (io/file path)]
67
+ (when (.exists f)
68
+ (with-open [r (java.io.PushbackReader. (io/reader f))]
69
+ (edn/read {:eof nil} r)))))
70
+
71
+ (defn- load-tasks []
72
+ (or (read-edn TASKS_PATH) []))
73
+
74
+ (defn- load-policy []
75
+ (or (read-edn POLICY_PATH)
76
+ {:allow ["src/**" "tests/**"]
77
+ :deny ["secrets/**" "**/*.pem"]
78
+ :limits {:max-lines-added 800
79
+ :max-review-attempts 5}}))
80
+
81
+ ;; =============================================================================
82
+ ;; Worker Management
83
+ ;; =============================================================================
84
+
85
+ (defn- create-worker [id worktree-instance]
86
+ {:id id
87
+ :status :idle
88
+ :worktree worktree-instance
89
+ :current-task nil
90
+ :review-loop nil})
91
+
92
+ (defn- assign-task [worker task]
93
+ (assoc worker
94
+ :status :proposing
95
+ :current-task task))
96
+
97
+ (defn- complete-task [worker result]
98
+ (assoc worker
99
+ :status :idle
100
+ :current-task nil
101
+ :review-loop nil
102
+ :last-result result))
103
+
104
+ ;; =============================================================================
105
+ ;; Task Processing
106
+ ;; =============================================================================
107
+
108
+ (defn- build-context [tasks]
109
+ (core/build-context {:tasks tasks
110
+ :policy (load-policy)
111
+ :recent-sec 180}))
112
+
113
+ (defn process-task!
114
+ "Process a single task through full lifecycle.
115
+
116
+ Steps:
117
+ 1. Run propose/review loop until approved
118
+ 2. Merge approved changes to main
119
+ 3. Clean up worktree
120
+
121
+ Returns TaskResult map."
122
+ [agent-config task context worktree opts]
123
+ (let [start-time (now-ms)
124
+ max-attempts (get-in opts [:limits :max-review-attempts] 5)
125
+
126
+ ;; Run review loop
127
+ loop-result (review/review-task!
128
+ agent-config
129
+ task
130
+ context
131
+ worktree
132
+ {:max-attempts max-attempts})
133
+
134
+ _ (println (format "[%s] Review: %s (%d attempts)"
135
+ (:id task)
136
+ (name (:status loop-result))
137
+ (review/attempt-count loop-result)))
138
+
139
+ ;; Merge if approved
140
+ merge-result (when (review/approved? loop-result)
141
+ (println (format "[%s] Merging..." (:id task)))
142
+ (merge/merge-worktree! worktree
143
+ {:strategy :no-ff
144
+ :dry-run (:dry-run opts)}))]
145
+
146
+ {:task-id (:id task)
147
+ :status (cond
148
+ (and merge-result (= :merged (:status merge-result))) :merged
149
+ (review/approved? loop-result) :merge-failed
150
+ (review/exhausted? loop-result) :review-exhausted
151
+ :else :failed)
152
+ :worker-id (:id worktree)
153
+ :started-at start-time
154
+ :completed-at (now-ms)
155
+ :review-attempts (review/attempt-count loop-result)
156
+ :merge-result merge-result
157
+ :error (or (:error loop-result)
158
+ (:error merge-result))}))
159
+
160
+ ;; =============================================================================
161
+ ;; Orchestrator Lifecycle
162
+ ;; =============================================================================
163
+
164
+ (defn create-orchestrator
165
+ "Initialize orchestrator with config.
166
+
167
+ Arguments:
168
+ config - OrchestratorConfig
169
+
170
+ Returns OrchestratorState"
171
+ [{:keys [worker-count harness model worktree-root dry-run] :as config}]
172
+ (schema/assert-valid schema/valid-orchestrator-config? config "OrchestratorConfig")
173
+
174
+ (let [model-str (if model (format " (model: %s)" model) "")]
175
+ (println (format "Initializing %d workers with %s harness%s..."
176
+ worker-count (name harness) model-str)))
177
+
178
+ ;; Initialize worktree pool
179
+ (let [pool (worktree/init-pool! config)
180
+ workers (mapv (fn [wt]
181
+ (create-worker (:id wt) wt))
182
+ pool)
183
+ tasks (load-tasks)]
184
+
185
+ (println (format "Loaded %d tasks, %d worktrees ready"
186
+ (count tasks) (count workers)))
187
+
188
+ {:config config
189
+ :workers workers
190
+ :worktree-pool pool
191
+ :task-queue (vec tasks)
192
+ :completed []
193
+ :failed []
194
+ :started-at (now-ms)}))
195
+
196
+ (defn run!
197
+ "Run orchestrator: process all tasks with parallel workers.
198
+
199
+ Uses core.async to parallelize across workers.
200
+ Returns updated OrchestratorState."
201
+ [state]
202
+ (let [{:keys [config workers worktree-pool task-queue]} state
203
+ {:keys [worker-count harness model review-harness review-model custom-prompt dry-run]} config
204
+ policy (load-policy)
205
+
206
+ ;; Proposer config
207
+ agent-config {:type harness
208
+ :model model
209
+ :sandbox :workspace-write
210
+ :timeout-seconds 300}
211
+
212
+ ;; Reviewer config (defaults to same as proposer)
213
+ reviewer-config {:type (or review-harness harness)
214
+ :model (or review-model model)
215
+ :sandbox :workspace-write
216
+ :timeout-seconds 300}
217
+
218
+ context (build-context task-queue)
219
+
220
+ ;; Channels for task distribution
221
+ task-ch (async/chan)
222
+ result-ch (async/chan)
223
+ merge-ch (async/chan) ; Serialize merges
224
+
225
+ ;; Worker processes
226
+ worker-procs
227
+ (doall
228
+ (for [worker workers]
229
+ (async/go-loop []
230
+ (when-let [task (async/<! task-ch)]
231
+ (let [wt (:worktree worker)
232
+ result (try
233
+ (process-task! agent-config task context wt
234
+ {:dry-run dry-run
235
+ :limits (:limits policy)})
236
+ (catch Exception e
237
+ {:task-id (:id task)
238
+ :status :error
239
+ :worker-id (:id worker)
240
+ :error (.getMessage e)}))]
241
+ (async/>! result-ch result)
242
+ ;; Reset worktree for next task
243
+ (worktree/release! worktree-pool (:id wt) {:reset? true})
244
+ (recur))))))
245
+
246
+ ;; Feed tasks to workers
247
+ _ (async/go
248
+ (doseq [task task-queue]
249
+ (async/>! task-ch task))
250
+ (async/close! task-ch))
251
+
252
+ ;; Collect results
253
+ results (loop [remaining (count task-queue)
254
+ acc []]
255
+ (if (zero? remaining)
256
+ acc
257
+ (if-let [result (async/<!! result-ch)]
258
+ (do
259
+ (println (format "[%s] -> %s"
260
+ (:task-id result)
261
+ (name (:status result))))
262
+ (recur (dec remaining) (conj acc result)))
263
+ acc)))]
264
+
265
+ ;; Close channels
266
+ (async/close! result-ch)
267
+
268
+ ;; Update state
269
+ (assoc state
270
+ :completed (filterv #(= :merged (:status %)) results)
271
+ :failed (filterv #(not= :merged (:status %)) results)
272
+ :run-log results
273
+ :finished-at (now-ms))))
274
+
275
+ (defn shutdown!
276
+ "Clean up orchestrator resources"
277
+ [state]
278
+ (println "Shutting down orchestrator...")
279
+ (worktree/cleanup-pool! (:worktree-pool state))
280
+ nil)
281
+
282
+ ;; =============================================================================
283
+ ;; Logging
284
+ ;; =============================================================================
285
+
286
+ (defn- ensure-dir! [path]
287
+ (.mkdirs (io/file path)))
288
+
289
+ (defn save-run-log!
290
+ "Save run results to JSONL file"
291
+ [state]
292
+ (ensure-dir! RUNS_DIR)
293
+ (let [timestamp (java.time.format.DateTimeFormatter/ofPattern "yyyyMMdd-HHmmss")
294
+ fname (format "run-%s.jsonl"
295
+ (.format timestamp (java.time.LocalDateTime/now)))
296
+ path (io/file RUNS_DIR fname)]
297
+ (doseq [entry (:run-log state)]
298
+ (spit path (str (json/generate-string entry) "\n") :append true))
299
+ (println (format "Run log: %s" (.getPath path)))
300
+ (.getPath path)))
301
+
302
+ (defn print-summary
303
+ "Print run summary to stdout"
304
+ [state]
305
+ (let [{:keys [completed failed started-at finished-at]} state
306
+ duration-sec (/ (- (or finished-at (now-ms)) started-at) 1000.0)]
307
+ (println)
308
+ (println "=== Run Summary ===")
309
+ (println (format "Duration: %.1fs" duration-sec))
310
+ (println (format "Completed: %d" (count completed)))
311
+ (println (format "Failed: %d" (count failed)))
312
+ (when (seq failed)
313
+ (println "Failed tasks:")
314
+ (doseq [{:keys [task-id status error]} failed]
315
+ (println (format " - %s: %s%s"
316
+ task-id
317
+ (name status)
318
+ (if error (str " (" error ")") "")))))))
319
+
320
+ ;; =============================================================================
321
+ ;; Convenience API
322
+ ;; =============================================================================
323
+
324
+ (defn run-once!
325
+ "Convenience: create orchestrator, run, save log, shutdown.
326
+
327
+ Arguments:
328
+ opts - {:workers N, :harness :codex|:claude, :model string,
329
+ :review-harness keyword, :review-model string,
330
+ :custom-prompt string, :dry-run bool}
331
+
332
+ Returns run log path"
333
+ [opts]
334
+ (let [config {:worker-count (or (:workers opts) 2)
335
+ :harness (or (:harness opts) :codex)
336
+ :model (:model opts)
337
+ :review-harness (:review-harness opts)
338
+ :review-model (:review-model opts)
339
+ :custom-prompt (:custom-prompt opts)
340
+ :worktree-root ".workers"
341
+ :dry-run (:dry-run opts false)
342
+ :policy (load-policy)}
343
+ state (-> (create-orchestrator config)
344
+ (run!))]
345
+ (print-summary state)
346
+ (let [log-path (save-run-log! state)]
347
+ (when-not (:keep-worktrees opts)
348
+ (shutdown! state))
349
+ log-path)))
350
+
351
+ (defn run-loop!
352
+ "Run orchestrator in a loop N times (like the bash loop).
353
+
354
+ Arguments:
355
+ iterations - Number of times to run
356
+ opts - Same as run-once!
357
+
358
+ Returns vector of log paths"
359
+ [iterations opts]
360
+ (loop [i 0
361
+ logs []]
362
+ (if (< i iterations)
363
+ (do
364
+ (println (format "\n=== Iteration %d/%d ===" (inc i) iterations))
365
+ (let [log (run-once! opts)]
366
+ (recur (inc i) (conj logs log))))
367
+ logs)))
@@ -0,0 +1,263 @@
1
+ (ns agentnet.review
2
+ "Propose-review-fix loop management.
3
+
4
+ The review loop orchestrates the back-and-forth between proposer and
5
+ reviewer agents until changes are approved or max attempts exhausted.
6
+
7
+ Flow:
8
+ 1. Proposer makes changes
9
+ 2. Reviewer evaluates changes
10
+ 3. If approved -> exit loop, ready to merge
11
+ 4. If needs-changes -> proposer fixes based on feedback
12
+ 5. Repeat until approved or max attempts
13
+
14
+ Design:
15
+ - Loop state is a pure data structure (ReviewLoop)
16
+ - Each step returns new state (functional)
17
+ - Side effects (agent calls) isolated to step functions
18
+ - Configurable max attempts prevents infinite loops"
19
+ (:require [agentnet.schema :as schema]
20
+ [agentnet.agent :as agent]
21
+ [agentnet.worktree :as worktree]
22
+ [clojure.string :as str]))
23
+
24
+ ;; =============================================================================
25
+ ;; Function Specs
26
+ ;; =============================================================================
27
+
28
+ ;; create-loop : Task, WorktreeId, MaxAttempts -> ReviewLoop
29
+ ;; Initialize a new review loop
30
+
31
+ ;; step! : ReviewLoop, AgentConfig, Context, Worktree -> ReviewLoop
32
+ ;; Execute one iteration of the loop (propose or review)
33
+
34
+ ;; run-loop! : ReviewLoop, AgentConfig, Context, Worktree -> ReviewLoop
35
+ ;; Run loop to completion (approved or exhausted)
36
+
37
+ ;; approved? : ReviewLoop -> Boolean
38
+ ;; Check if loop ended with approval
39
+
40
+ ;; =============================================================================
41
+ ;; Constants
42
+ ;; =============================================================================
43
+
44
+ (def ^:const DEFAULT_MAX_ATTEMPTS 5)
45
+
46
+ ;; =============================================================================
47
+ ;; State Management
48
+ ;; =============================================================================
49
+
50
+ (defn- now-ms []
51
+ (System/currentTimeMillis))
52
+
53
+ (defn create-loop
54
+ "Create a new review loop for a task"
55
+ [task-id worktree-id {:keys [max-attempts] :or {max-attempts DEFAULT_MAX_ATTEMPTS}}]
56
+ {:task-id task-id
57
+ :worktree-id worktree-id
58
+ :max-attempts max-attempts
59
+ :attempts []
60
+ :status :in-progress})
61
+
62
+ (defn approved?
63
+ "Check if loop completed with approval"
64
+ [loop-state]
65
+ (= :approved (:status loop-state)))
66
+
67
+ (defn exhausted?
68
+ "Check if loop exhausted max attempts"
69
+ [loop-state]
70
+ (= :exhausted (:status loop-state)))
71
+
72
+ (defn attempt-count
73
+ "Get number of attempts so far"
74
+ [loop-state]
75
+ (count (:attempts loop-state)))
76
+
77
+ (defn last-feedback
78
+ "Get feedback from most recent attempt"
79
+ [loop-state]
80
+ (-> loop-state :attempts last :feedback))
81
+
82
+ (defn can-continue?
83
+ "Check if loop can continue (not done, not exhausted)"
84
+ [loop-state]
85
+ (and (= :in-progress (:status loop-state))
86
+ (< (attempt-count loop-state) (:max-attempts loop-state))))
87
+
88
+ ;; =============================================================================
89
+ ;; Feedback Formatting
90
+ ;; =============================================================================
91
+
92
+ (defn- format-feedback-for-proposer
93
+ "Format review feedback as instructions for proposer to fix"
94
+ [feedback]
95
+ (let [{:keys [verdict comments violations suggested-fixes]} feedback
96
+ sections (cond-> []
97
+ (seq violations)
98
+ (conj (str "Violations:\n"
99
+ (str/join "\n" (map #(str "- " %) violations))))
100
+
101
+ (seq comments)
102
+ (conj (str "Feedback:\n"
103
+ (str/join "\n" (map #(str "- " %) comments))))
104
+
105
+ (seq suggested-fixes)
106
+ (conj (str "Suggested fixes:\n"
107
+ (str/join "\n" (map #(str "- " %) suggested-fixes)))))]
108
+ (if (seq sections)
109
+ (str "Previous review feedback:\n\n"
110
+ (str/join "\n\n" sections)
111
+ "\n\nPlease address the above issues.")
112
+ "Please review and improve the implementation.")))
113
+
114
+ ;; =============================================================================
115
+ ;; Step Execution
116
+ ;; =============================================================================
117
+
118
+ (defn- run-proposer!
119
+ "Execute proposer agent, commit changes"
120
+ [agent-config task context worktree feedback]
121
+ (let [;; Augment context with feedback if this is a retry
122
+ augmented-context (if feedback
123
+ (assoc context :review_feedback
124
+ (format-feedback-for-proposer feedback))
125
+ context)
126
+ result (agent/propose! agent-config task augmented-context worktree)]
127
+ (when (zero? (:exit result))
128
+ ;; Commit changes in worktree
129
+ (let [msg (if feedback
130
+ (format "fix: address review feedback for %s" (:id task))
131
+ (format "feat: implement %s" (:id task)))]
132
+ (worktree/commit-in-worktree! worktree msg)))
133
+ result))
134
+
135
+ (defn- run-reviewer!
136
+ "Execute reviewer agent on current worktree state"
137
+ [agent-config task context worktree]
138
+ (agent/review! agent-config task context worktree))
139
+
140
+ (defn- create-attempt
141
+ "Create an attempt record from review result"
142
+ [attempt-number review-result worktree]
143
+ {:attempt-number attempt-number
144
+ :timestamp (now-ms)
145
+ :feedback (or (:parsed review-result)
146
+ {:verdict :needs-changes
147
+ :comments ["Review failed to produce structured output"]})
148
+ :patch-hash nil}) ; TODO: compute hash of current diff
149
+
150
+ (defn step!
151
+ "Execute one iteration of propose->review.
152
+
153
+ Returns updated ReviewLoop with new attempt recorded."
154
+ [loop-state agent-config task context worktree]
155
+ (when-not (can-continue? loop-state)
156
+ (throw (ex-info "Cannot continue loop" {:loop loop-state})))
157
+
158
+ (let [attempt-num (inc (attempt-count loop-state))
159
+ prev-feedback (last-feedback loop-state)
160
+
161
+ ;; Step 1: Proposer makes/fixes changes
162
+ propose-result (run-proposer! agent-config task context worktree prev-feedback)
163
+
164
+ _ (when-not (zero? (:exit propose-result))
165
+ (throw (ex-info "Proposer failed"
166
+ {:exit (:exit propose-result)
167
+ :stderr (:stderr propose-result)})))
168
+
169
+ ;; Step 2: Reviewer evaluates
170
+ review-result (run-reviewer! agent-config task context worktree)
171
+
172
+ ;; Record attempt
173
+ attempt (create-attempt attempt-num review-result worktree)
174
+ verdict (get-in attempt [:feedback :verdict])
175
+
176
+ ;; Update loop state
177
+ new-attempts (conj (:attempts loop-state) attempt)
178
+ new-status (cond
179
+ (= verdict :approved) :approved
180
+ (>= attempt-num (:max-attempts loop-state)) :exhausted
181
+ :else :in-progress)]
182
+
183
+ (assoc loop-state
184
+ :attempts new-attempts
185
+ :status new-status)))
186
+
187
+ ;; =============================================================================
188
+ ;; Full Loop Execution
189
+ ;; =============================================================================
190
+
191
+ (defn run-loop!
192
+ "Run the review loop to completion.
193
+
194
+ Continues until:
195
+ - Approved: reviewer accepts changes
196
+ - Exhausted: max attempts reached
197
+ - Aborted: unrecoverable error
198
+
199
+ Returns final ReviewLoop state."
200
+ [loop-state agent-config task context worktree]
201
+ (loop [state loop-state]
202
+ (if (can-continue? state)
203
+ (let [new-state (try
204
+ (step! state agent-config task context worktree)
205
+ (catch Exception e
206
+ (assoc state
207
+ :status :aborted
208
+ :error (.getMessage e))))]
209
+ (if (= :in-progress (:status new-state))
210
+ (recur new-state)
211
+ new-state))
212
+ state)))
213
+
214
+ ;; =============================================================================
215
+ ;; Convenience API
216
+ ;; =============================================================================
217
+
218
+ (defn review-task!
219
+ "Complete review flow for a task: create loop, run to completion.
220
+
221
+ Arguments:
222
+ agent-config - AgentConfig for proposer/reviewer
223
+ task - Task to implement
224
+ context - Context map for prompts
225
+ worktree - Worktree to work in
226
+ opts - {:max-attempts N}
227
+
228
+ Returns final ReviewLoop state"
229
+ [agent-config task context worktree opts]
230
+ (let [loop-state (create-loop (:id task) (:id worktree) opts)]
231
+ (run-loop! loop-state agent-config task context worktree)))
232
+
233
+ (defn summarize-loop
234
+ "Generate human-readable summary of review loop"
235
+ [loop-state]
236
+ (let [{:keys [task-id status attempts max-attempts]} loop-state
237
+ attempt-summaries (map (fn [{:keys [attempt-number feedback]}]
238
+ (format " Attempt %d: %s"
239
+ attempt-number
240
+ (name (:verdict feedback :unknown))))
241
+ attempts)]
242
+ (str (format "Task: %s\n" task-id)
243
+ (format "Status: %s\n" (name status))
244
+ (format "Attempts: %d/%d\n" (count attempts) max-attempts)
245
+ (when (seq attempt-summaries)
246
+ (str "History:\n" (str/join "\n" attempt-summaries))))))
247
+
248
+ ;; =============================================================================
249
+ ;; Logging / Persistence
250
+ ;; =============================================================================
251
+
252
+ (defn loop->log-entry
253
+ "Convert loop state to log entry format"
254
+ [loop-state]
255
+ {:task-id (:task-id loop-state)
256
+ :status (case (:status loop-state)
257
+ :approved :merged ; will become merged after actual merge
258
+ :exhausted :failed
259
+ :aborted :failed
260
+ :in-progress)
261
+ :review-attempts (attempt-count loop-state)
262
+ :final-verdict (get-in (last-feedback loop-state) [:verdict])
263
+ :error (:error loop-state)})