@nbardy/oompa 0.7.0 → 0.7.2

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,240 @@
1
+ (ns agentnet.harness
2
+ "Harness configuration registry.
3
+
4
+ Each harness is a data map describing how to invoke a CLI agent.
5
+ CLI flag syntax is owned by agent-cli (shared with claude-web-view).
6
+ This module owns: stdin behavior, session strategy, output parsing.
7
+
8
+ Harness = Codex | Claude | Opencode | Gemini
9
+ δ(harness) → config map → agent-cli JSON → command vector"
10
+ (:require [babashka.process :as process]
11
+ [cheshire.core :as json]
12
+ [clojure.string :as str]))
13
+
14
+ ;; =============================================================================
15
+ ;; Binary Resolution (shared across all harnesses)
16
+ ;; =============================================================================
17
+
18
+ (def ^:private binary-paths* (atom {}))
19
+
20
+ (defn resolve-binary!
21
+ "Resolve the absolute path of a CLI binary. Caches result.
22
+ Throws if binary not found on PATH.
23
+ ProcessBuilder with :dir can fail to find bare command names on macOS/babashka,
24
+ so we resolve once via `which` and cache."
25
+ [name]
26
+ (or (get @binary-paths* name)
27
+ (let [result (try
28
+ (process/sh ["which" name] {:out :string :err :string})
29
+ (catch Exception _ {:exit -1 :out "" :err ""}))]
30
+ (if (zero? (:exit result))
31
+ (let [path (str/trim (:out result))]
32
+ (swap! binary-paths* assoc name path)
33
+ path)
34
+ (throw (ex-info (str "Binary not found on PATH: " name) {:binary name}))))))
35
+
36
+ ;; =============================================================================
37
+ ;; NDJSON Output Parsing (harness-specific, lives here not in agent-cli)
38
+ ;; =============================================================================
39
+
40
+ (defn- parse-ndjson-output
41
+ "Parse NDJSON output from CLIs (opencode, gemini).
42
+ Returns {:session-id string|nil, :text string|nil}."
43
+ [s]
44
+ (let [raw (or s "")
45
+ events (->> (str/split-lines raw)
46
+ (keep (fn [line]
47
+ (try
48
+ (json/parse-string line true)
49
+ (catch Exception _
50
+ nil))))
51
+ doall)
52
+ session-id (or (some #(or (:sessionID %)
53
+ (:sessionId %)
54
+ (:session_id %)
55
+ (get-in % [:part :sessionID])
56
+ (get-in % [:part :sessionId]))
57
+ events)
58
+ (some-> (re-find #"(ses_[A-Za-z0-9]+)" raw) second))
59
+ text (->> events
60
+ (keep (fn [event]
61
+ (let [event-type (or (:type event) (get-in event [:part :type]))
62
+ role (or (:role event) (get-in event [:part :role]))
63
+ chunk (or (:text event) (get-in event [:part :text]) (:content event))]
64
+ (when (and (or (= event-type "text")
65
+ (and (= event-type "message")
66
+ (= role "assistant")))
67
+ (string? chunk)
68
+ (not (str/blank? chunk)))
69
+ chunk))))
70
+ (str/join ""))]
71
+ {:session-id session-id
72
+ :text (when-not (str/blank? text) text)}))
73
+
74
+ ;; =============================================================================
75
+ ;; Env Helpers
76
+ ;; =============================================================================
77
+
78
+ (defn- opencode-attach-url
79
+ "Optional opencode server URL for run --attach mode."
80
+ []
81
+ (let [url (or (System/getenv "OOMPA_OPENCODE_ATTACH")
82
+ (System/getenv "OPENCODE_ATTACH"))]
83
+ (when (and url (not (str/blank? url)))
84
+ url)))
85
+
86
+ ;; =============================================================================
87
+ ;; Harness Registry — oompa-specific behavior fields only
88
+ ;; =============================================================================
89
+ ;;
90
+ ;; CLI flag syntax (model flags, session flags, bypass, prompt delivery)
91
+ ;; is owned by agent-cli. This registry only tracks:
92
+ ;;
93
+ ;; :stdin - what to pass as process stdin (:close or :prompt)
94
+ ;; :session - session ID strategy (:uuid, :extracted, :implicit)
95
+ ;; :output - output format (:plain or :ndjson)
96
+
97
+ (def ^:private gemini-behavior
98
+ {:stdin :close :session :implicit :output :ndjson})
99
+
100
+ (def registry
101
+ (merge
102
+ {:codex {:stdin :close :session :uuid :output :plain}
103
+ :claude {:stdin :prompt :session :uuid :output :plain}
104
+ :opencode {:stdin :close :session :extracted :output :ndjson}
105
+ :gemini gemini-behavior}
106
+ {:gemini1 gemini-behavior
107
+ :gemini2 gemini-behavior
108
+ :gemini3 gemini-behavior}))
109
+
110
+ (defn- gemini-alias?
111
+ [harness-kw]
112
+ (and (keyword? harness-kw)
113
+ (re-matches #"^gemini\\d+$" (name harness-kw))))
114
+
115
+ (defn valid-harness?
116
+ "True for explicit registry entries and any `geminiNN` alias."
117
+ [harness-kw]
118
+ (or (contains? (set (keys registry)) harness-kw)
119
+ (gemini-alias? harness-kw)))
120
+
121
+ ;; =============================================================================
122
+ ;; Registry Access
123
+ ;; =============================================================================
124
+
125
+ (defn get-config
126
+ "Look up harness config. Throws on unknown harness (no silent fallback)."
127
+ [harness-kw]
128
+ (or (get registry harness-kw)
129
+ (when (gemini-alias? harness-kw) gemini-behavior)
130
+ (throw (ex-info (str "Unknown harness: " harness-kw
131
+ ". Known: " (str/join ", " (map name (keys registry))))
132
+ {:harness harness-kw}))))
133
+
134
+ (defn known-harnesses
135
+ "Set of all registered harness keywords."
136
+ []
137
+ (set (keys registry)))
138
+
139
+ ;; =============================================================================
140
+ ;; Command Builder — delegates to agent-cli via JSON input
141
+ ;; =============================================================================
142
+
143
+ (defn- add-extra-args
144
+ "Attach harness-specific extraArgs to the JSON input map.
145
+ Only opencode needs extra flags (--format json, --attach)."
146
+ [m harness-kw opts]
147
+ (if (= harness-kw :opencode)
148
+ (let [url (opencode-attach-url)
149
+ extra (cond-> []
150
+ (:format? opts) (into ["--format" "json" "--print-logs" "--log-level" "WARN"])
151
+ url (into ["--attach" url]))]
152
+ (if (seq extra) (assoc m :extraArgs extra) m))
153
+ m))
154
+
155
+ (defn build-cmd
156
+ "Build CLI command vector via agent-cli JSON input.
157
+ Sends a JSON dict to `agent-cli build --input -`, parses the CommandSpec,
158
+ and resolves the binary to an absolute path.
159
+
160
+ agent-cli owns all CLI flag syntax (session create/resume, model decomposition,
161
+ bypass flags, prompt delivery). This function just maps oompa opts to JSON."
162
+ [harness-kw opts]
163
+ (let [input (-> {:harness (name harness-kw)
164
+ :bypassPermissions true}
165
+ (cond->
166
+ (:model opts) (assoc :model (:model opts))
167
+ (:prompt opts) (assoc :prompt (:prompt opts))
168
+ (:session-id opts) (assoc :sessionId (:session-id opts))
169
+ (:resume? opts) (assoc :resume true)
170
+ (:cwd opts) (assoc :cwd (:cwd opts))
171
+ (:reasoning opts) (assoc :reasoning (:reasoning opts)))
172
+ (add-extra-args harness-kw opts)
173
+ json/generate-string)
174
+ {:keys [exit out err]} (process/sh ["agent-cli" "build" "--input" "-"]
175
+ {:in input :out :string :err :string})]
176
+ (when-not (zero? exit)
177
+ (throw (ex-info (str "agent-cli: " (str/trim err)) {:exit exit})))
178
+ (let [argv (:argv (json/parse-string out true))]
179
+ (vec (cons (resolve-binary! (first argv)) (rest argv))))))
180
+
181
+ (defn process-stdin
182
+ "Return the :in value for process/sh.
183
+ :close → \"\" (close stdin immediately to prevent hang).
184
+ :prompt → the prompt text (claude delivers prompt via stdin)."
185
+ [harness-kw prompt]
186
+ (let [{:keys [stdin]} (get-config harness-kw)]
187
+ (case stdin
188
+ :prompt prompt
189
+ :close "")))
190
+
191
+ ;; =============================================================================
192
+ ;; Session Management
193
+ ;; =============================================================================
194
+
195
+ (defn make-session-id
196
+ "Generate initial session-id based on harness strategy.
197
+ :uuid → random UUID string.
198
+ :extracted → nil (will be parsed from output after first run).
199
+ :implicit → nil (harness manages sessions by cwd, e.g. gemini)."
200
+ [harness-kw]
201
+ (let [{:keys [session]} (get-config harness-kw)]
202
+ (when (= session :uuid)
203
+ (str/lower-case (str (java.util.UUID/randomUUID))))))
204
+
205
+ ;; =============================================================================
206
+ ;; Output Parsing
207
+ ;; =============================================================================
208
+
209
+ (defn parse-output
210
+ "Parse agent output. For :ndjson harnesses, extracts session-id and text.
211
+ For :plain, returns output as-is.
212
+ Returns {:output string, :session-id string}."
213
+ [harness-kw raw-output session-id]
214
+ (let [{:keys [output]} (get-config harness-kw)]
215
+ (if (= output :ndjson)
216
+ (let [parsed (parse-ndjson-output raw-output)]
217
+ {:output (or (:text parsed) raw-output)
218
+ :session-id (or (:session-id parsed) session-id)})
219
+ {:output raw-output
220
+ :session-id session-id})))
221
+
222
+ ;; =============================================================================
223
+ ;; Probe / Health Check — delegates to agent-cli check
224
+ ;; =============================================================================
225
+
226
+ (defn check-available
227
+ "Check if harness CLI binary is available on PATH via agent-cli."
228
+ [harness-kw]
229
+ (try
230
+ (let [{:keys [exit out]} (process/sh ["agent-cli" "check" (name harness-kw)]
231
+ {:out :string :err :string})]
232
+ (when (zero? exit)
233
+ (:available (json/parse-string out true))))
234
+ (catch Exception _ false)))
235
+
236
+ (defn build-probe-cmd
237
+ "Build minimal command to test if a harness+model is accessible.
238
+ Delegates to build-cmd with a '[_HIDE_TEST_] say ok' probe prompt."
239
+ [harness-kw model]
240
+ (build-cmd harness-kw {:model model :prompt "[_HIDE_TEST_] say ok"}))
@@ -286,6 +286,8 @@
286
286
  (defn- ensure-dir! [path]
287
287
  (.mkdirs (io/file path)))
288
288
 
289
+ ;; DEAD CODE: save-run-log!, run-once!, run-loop! are unreachable since
290
+ ;; cmd-run/cmd-loop simple-mode was retired 2026-02-17. Delete in a future pass.
289
291
  (defn save-run-log!
290
292
  "Save run results to JSONL file"
291
293
  [state]
@@ -1,13 +1,16 @@
1
1
  (ns agentnet.runs
2
- "Structured persistence for swarm runs.
2
+ "Structured persistence for swarm runs — event-sourced immutable logs.
3
3
 
4
4
  Layout:
5
5
  runs/{swarm-id}/
6
- run.json — start time, worker configs, planner output
7
- summary.json final metrics per worker, aggregate stats
6
+ started.json swarm start event (PID, worker configs, planner)
7
+ stopped.json swarm stop event (reason, optional error)
8
+ cycles/
9
+ {worker-id}-c{N}.json — per-cycle event log (one complete work unit)
8
10
  reviews/
9
- {worker-id}-i{N}-r{round}.json — per-iteration review log
11
+ {worker-id}-c{N}-r{round}.json — per-cycle review log
10
12
 
13
+ All state is derived by reading event logs. No mutable summary files.
11
14
  Written as JSON for easy consumption by claude-web-view and CLI.
12
15
  All writes are atomic (write to .tmp, rename) to avoid partial reads."
13
16
  (:require [clojure.java.io :as io]
@@ -26,8 +29,12 @@
26
29
  (defn- reviews-dir [swarm-id]
27
30
  (str (run-dir swarm-id) "/reviews"))
28
31
 
32
+ (defn- cycles-dir [swarm-id]
33
+ (str (run-dir swarm-id) "/cycles"))
34
+
29
35
  (defn- ensure-run-dirs! [swarm-id]
30
- (.mkdirs (io/file (reviews-dir swarm-id))))
36
+ (.mkdirs (io/file (reviews-dir swarm-id)))
37
+ (.mkdirs (io/file (cycles-dir swarm-id))))
31
38
 
32
39
  ;; =============================================================================
33
40
  ;; Atomic Write
@@ -43,23 +50,26 @@
43
50
  (.renameTo tmp f)))
44
51
 
45
52
  ;; =============================================================================
46
- ;; Run Log — written at swarm start
53
+ ;; Started Event — written at swarm start
47
54
  ;; =============================================================================
48
55
 
49
- (defn write-run-log!
50
- "Write the initial run log when a swarm starts.
51
- Contains: start time, worker configs, planner output, swarm metadata."
56
+ (defn write-started!
57
+ "Write the started event when a swarm begins.
58
+ Contains: start time, PID, worker configs, planner output, swarm metadata."
52
59
  [swarm-id {:keys [workers planner-config reviewer-config config-file]}]
53
60
  (ensure-run-dirs! swarm-id)
54
- (write-json! (str (run-dir swarm-id) "/run.json")
61
+ (write-json! (str (run-dir swarm-id) "/started.json")
55
62
  {:swarm-id swarm-id
56
63
  :started-at (str (java.time.Instant/now))
64
+ :pid (.pid (java.lang.ProcessHandle/current))
57
65
  :config-file config-file
58
66
  :workers (mapv (fn [w]
59
67
  {:id (:id w)
60
68
  :harness (name (:harness w))
61
- :model (:model w)
62
- :reasoning (:reasoning w)
69
+ :model (:model w)
70
+ :reasoning (:reasoning w)
71
+ :runs (:runs w)
72
+ :max-cycles (:max-cycles w)
63
73
  :iterations (:iterations w)
64
74
  :can-plan (:can-plan w)
65
75
  :prompts (:prompts w)})
@@ -74,20 +84,33 @@
74
84
  :model (:model reviewer-config)
75
85
  :prompts (:prompts reviewer-config)})}))
76
86
 
87
+ ;; =============================================================================
88
+ ;; Stopped Event — written at swarm end
89
+ ;; =============================================================================
90
+
91
+ (defn write-stopped!
92
+ "Write the stopped event when a swarm exits cleanly."
93
+ [swarm-id reason & {:keys [error]}]
94
+ (write-json! (str (run-dir swarm-id) "/stopped.json")
95
+ {:swarm-id swarm-id
96
+ :stopped-at (str (java.time.Instant/now))
97
+ :reason (name reason)
98
+ :error error}))
99
+
77
100
  ;; =============================================================================
78
101
  ;; Review Log — written after each review round
79
102
  ;; =============================================================================
80
103
 
81
104
  (defn write-review-log!
82
- "Write a review log for one iteration of a worker.
105
+ "Write a review log for one cycle of a worker.
83
106
  Contains: verdict, round number, full reviewer output, diff file list."
84
- [swarm-id worker-id iteration round
107
+ [swarm-id worker-id cycle round
85
108
  {:keys [verdict output diff-files]}]
86
109
  (ensure-run-dirs! swarm-id)
87
- (let [filename (format "%s-i%d-r%d.json" worker-id iteration round)]
110
+ (let [filename (format "%s-c%d-r%d.json" worker-id cycle round)]
88
111
  (write-json! (str (reviews-dir swarm-id) "/" filename)
89
112
  {:worker-id worker-id
90
- :iteration iteration
113
+ :cycle cycle
91
114
  :round round
92
115
  :verdict (name verdict)
93
116
  :timestamp (str (java.time.Instant/now))
@@ -95,36 +118,31 @@
95
118
  :diff-files (vec diff-files)})))
96
119
 
97
120
  ;; =============================================================================
98
- ;; Swarm Summary — written at swarm end
121
+ ;; Cycle Log — written per cycle for real-time dashboard visibility
99
122
  ;; =============================================================================
100
123
 
101
- (defn write-summary!
102
- "Write the final swarm summary after all workers complete.
103
- Contains: per-worker stats and aggregate metrics."
104
- [swarm-id worker-results]
105
- (let [total-completed (reduce + 0 (map :completed worker-results))
106
- total-iterations (reduce + 0 (map :iterations worker-results))
107
- statuses (frequencies (map #(name (:status %)) worker-results))
108
- per-worker (mapv (fn [w]
109
- {:id (:id w)
110
- :harness (name (:harness w))
111
- :model (:model w)
112
- :status (name (:status w))
113
- :completed (:completed w)
114
- :iterations (:iterations w)
115
- :merges (or (:merges w) 0)
116
- :rejections (or (:rejections w) 0)
117
- :errors (or (:errors w) 0)
118
- :review-rounds-total (or (:review-rounds-total w) 0)})
119
- worker-results)]
120
- (write-json! (str (run-dir swarm-id) "/summary.json")
121
- {:swarm-id swarm-id
122
- :finished-at (str (java.time.Instant/now))
123
- :total-workers (count worker-results)
124
- :total-completed total-completed
125
- :total-iterations total-iterations
126
- :status-counts statuses
127
- :workers per-worker})))
124
+ (defn write-cycle-log!
125
+ "Write a cycle event log for a worker.
126
+ Contains: outcome, timing, claimed task IDs, recycled tasks, session-id.
127
+ session-id links to the Claude CLI conversation on disk for debugging.
128
+ Written at cycle end so dashboards can track progress in real-time."
129
+ [swarm-id worker-id cycle
130
+ {:keys [run outcome duration-ms claimed-task-ids recycled-tasks
131
+ error-snippet review-rounds session-id]}]
132
+ (when swarm-id
133
+ (let [filename (format "%s-c%d.json" worker-id cycle)]
134
+ (write-json! (str (cycles-dir swarm-id) "/" filename)
135
+ {:worker-id worker-id
136
+ :cycle cycle
137
+ :run (or run 1)
138
+ :outcome (name outcome)
139
+ :timestamp (str (java.time.Instant/now))
140
+ :duration-ms duration-ms
141
+ :claimed-task-ids (or claimed-task-ids [])
142
+ :recycled-tasks (or recycled-tasks [])
143
+ :error-snippet error-snippet
144
+ :review-rounds (or review-rounds 0)
145
+ :session-id session-id}))))
128
146
 
129
147
  ;; =============================================================================
130
148
  ;; Read helpers (for cmd-status, dashboards)
@@ -140,17 +158,17 @@
140
158
  (sort-by #(.lastModified %) >)
141
159
  (mapv #(.getName %))))))
142
160
 
143
- (defn read-run-log
144
- "Read run.json for a swarm."
161
+ (defn read-started
162
+ "Read started.json for a swarm."
145
163
  [swarm-id]
146
- (let [f (io/file (str (run-dir swarm-id) "/run.json"))]
164
+ (let [f (io/file (str (run-dir swarm-id) "/started.json"))]
147
165
  (when (.exists f)
148
166
  (json/parse-string (slurp f) true))))
149
167
 
150
- (defn read-summary
151
- "Read summary.json for a swarm."
168
+ (defn read-stopped
169
+ "Read stopped.json for a swarm. Returns nil if still running."
152
170
  [swarm-id]
153
- (let [f (io/file (str (run-dir swarm-id) "/summary.json"))]
171
+ (let [f (io/file (str (run-dir swarm-id) "/stopped.json"))]
154
172
  (when (.exists f)
155
173
  (json/parse-string (slurp f) true))))
156
174
 
@@ -163,3 +181,13 @@
163
181
  (filter #(str/ends-with? (.getName %) ".json"))
164
182
  (sort-by #(.getName %))
165
183
  (mapv (fn [f] (json/parse-string (slurp f) true)))))))
184
+
185
+ (defn list-cycles
186
+ "List all cycle logs for a swarm, sorted by filename."
187
+ [swarm-id]
188
+ (let [d (io/file (cycles-dir swarm-id))]
189
+ (when (.exists d)
190
+ (->> (.listFiles d)
191
+ (filter #(str/ends-with? (.getName %) ".json"))
192
+ (sort-by #(.getName %))
193
+ (mapv (fn [f] (json/parse-string (slurp f) true)))))))
@@ -40,7 +40,7 @@
40
40
  ;; Enum Validators
41
41
  ;; =============================================================================
42
42
 
43
- (def agent-types #{:codex :claude :opencode})
43
+ (def agent-types #{:codex :claude :opencode :gemini})
44
44
  (def agent-roles #{:proposer :reviewer :cto})
45
45
  (def task-statuses #{:pending :in-progress :review :approved :merged :failed :blocked})
46
46
  (def worktree-statuses #{:available :busy :dirty :stale})
@@ -48,7 +48,14 @@
48
48
  (def merge-strategies #{:fast-forward :no-ff :squash :rebase})
49
49
  (def conflict-resolutions #{:ours :theirs :manual :abort})
50
50
 
51
- (defn agent-type? [x] (contains? agent-types x))
51
+ (defn- gemini-indexed-account?
52
+ [x]
53
+ (and (keyword? x)
54
+ (re-matches #"^gemini\d+$" (name x))))
55
+
56
+ (defn agent-type? [x]
57
+ (or (contains? agent-types x)
58
+ (gemini-indexed-account? x)))
52
59
  (defn agent-role? [x] (contains? agent-roles x))
53
60
  (defn task-status? [x] (contains? task-statuses x))
54
61
  (defn worktree-status? [x] (contains? worktree-statuses x))
@@ -123,6 +123,35 @@
123
123
  (when-let [task (first (list-pending))]
124
124
  (claim-task! task)))
125
125
 
126
+ (defn claim-by-id!
127
+ "Claim a pending task by its :id string. Atomically moves pending → current.
128
+ Returns {:status :claimed|:already-taken|:not-found, :id id}."
129
+ [task-id]
130
+ (let [match (first (filter #(= (:id %) task-id) (list-pending)))]
131
+ (if match
132
+ (if (claim-task! match)
133
+ {:status :claimed :id task-id}
134
+ {:status :already-taken :id task-id})
135
+ ;; Not in pending — check if already in current (raced)
136
+ (if (some #(= (:id %) task-id) (list-current))
137
+ {:status :already-taken :id task-id}
138
+ {:status :not-found :id task-id}))))
139
+
140
+ (defn claim-by-ids!
141
+ "Claim multiple tasks by ID. Returns vector of result maps."
142
+ [task-ids]
143
+ (mapv claim-by-id! task-ids))
144
+
145
+ (defn complete-by-ids!
146
+ "Move tasks from current → complete by ID. Framework-owned completion.
147
+ Returns vector of completed task IDs."
148
+ [task-ids]
149
+ (let [id-set (set task-ids)
150
+ to-complete (filter #(id-set (:id %)) (list-current))]
151
+ (doseq [task to-complete]
152
+ (complete-task! task))
153
+ (mapv :id to-complete)))
154
+
126
155
  ;; =============================================================================
127
156
  ;; Task Creation
128
157
  ;; =============================================================================
@@ -163,6 +192,24 @@
163
192
  (defn current-count [] (count (list-task-files CURRENT_DIR)))
164
193
  (defn complete-count [] (count (list-task-files COMPLETE_DIR)))
165
194
 
195
+ (defn current-task-ids
196
+ "Return a set of task IDs currently in current/."
197
+ []
198
+ (->> (list-task-files CURRENT_DIR)
199
+ (keep read-task-file)
200
+ (map :id)
201
+ set))
202
+
203
+ (defn recycle-tasks!
204
+ "Move specific tasks from current/ back to pending/ by ID.
205
+ Returns vector of recycled task IDs."
206
+ [task-ids]
207
+ (let [current (list-current)
208
+ to-recycle (filter #(task-ids (:id %)) current)]
209
+ (doseq [task to-recycle]
210
+ (unclaim-task! task))
211
+ (mapv :id to-recycle)))
212
+
166
213
  (defn all-complete?
167
214
  "True if no pending or current tasks"
168
215
  []