@nbardy/oompa 0.7.0 → 0.7.1

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,217 @@
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
+ ;; Opencode Output Parsing (harness-specific, lives here not in agent-cli)
38
+ ;; =============================================================================
39
+
40
+ (defn- parse-opencode-run-output
41
+ "Parse `opencode run --format json` NDJSON output.
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
+ (get-in % [:part :sessionID])
55
+ (get-in % [:part :sessionId]))
56
+ events)
57
+ (some-> (re-find #"(ses_[A-Za-z0-9]+)" raw) second))
58
+ text (->> events
59
+ (keep (fn [event]
60
+ (let [event-type (or (:type event) (get-in event [:part :type]))
61
+ chunk (or (:text event) (get-in event [:part :text]))]
62
+ (when (and (= event-type "text")
63
+ (string? chunk)
64
+ (not (str/blank? chunk)))
65
+ chunk))))
66
+ (str/join ""))]
67
+ {:session-id session-id
68
+ :text (when-not (str/blank? text) text)}))
69
+
70
+ ;; =============================================================================
71
+ ;; Env Helpers
72
+ ;; =============================================================================
73
+
74
+ (defn- opencode-attach-url
75
+ "Optional opencode server URL for run --attach mode."
76
+ []
77
+ (let [url (or (System/getenv "OOMPA_OPENCODE_ATTACH")
78
+ (System/getenv "OPENCODE_ATTACH"))]
79
+ (when (and url (not (str/blank? url)))
80
+ url)))
81
+
82
+ ;; =============================================================================
83
+ ;; Harness Registry — oompa-specific behavior fields only
84
+ ;; =============================================================================
85
+ ;;
86
+ ;; CLI flag syntax (model flags, session flags, bypass, prompt delivery)
87
+ ;; is owned by agent-cli. This registry only tracks:
88
+ ;;
89
+ ;; :stdin - what to pass as process stdin (:close or :prompt)
90
+ ;; :session - session ID strategy (:uuid, :extracted, :implicit)
91
+ ;; :output - output format (:plain or :ndjson)
92
+
93
+ (def registry
94
+ {:codex {:stdin :close :session :uuid :output :plain}
95
+ :claude {:stdin :prompt :session :uuid :output :plain}
96
+ :opencode {:stdin :close :session :extracted :output :ndjson}
97
+ :gemini {:stdin :close :session :implicit :output :plain}})
98
+
99
+ ;; =============================================================================
100
+ ;; Registry Access
101
+ ;; =============================================================================
102
+
103
+ (defn get-config
104
+ "Look up harness config. Throws on unknown harness (no silent fallback)."
105
+ [harness-kw]
106
+ (or (get registry harness-kw)
107
+ (throw (ex-info (str "Unknown harness: " harness-kw
108
+ ". Known: " (str/join ", " (map name (keys registry))))
109
+ {:harness harness-kw}))))
110
+
111
+ (defn known-harnesses
112
+ "Set of all registered harness keywords."
113
+ []
114
+ (set (keys registry)))
115
+
116
+ ;; =============================================================================
117
+ ;; Command Builder — delegates to agent-cli via JSON input
118
+ ;; =============================================================================
119
+
120
+ (defn- add-extra-args
121
+ "Attach harness-specific extraArgs to the JSON input map.
122
+ Only opencode needs extra flags (--format json, --attach)."
123
+ [m harness-kw opts]
124
+ (if (= harness-kw :opencode)
125
+ (let [url (opencode-attach-url)
126
+ extra (cond-> []
127
+ (:format? opts) (into ["--format" "json" "--print-logs" "--log-level" "WARN"])
128
+ url (into ["--attach" url]))]
129
+ (if (seq extra) (assoc m :extraArgs extra) m))
130
+ m))
131
+
132
+ (defn build-cmd
133
+ "Build CLI command vector via agent-cli JSON input.
134
+ Sends a JSON dict to `agent-cli build --input -`, parses the CommandSpec,
135
+ and resolves the binary to an absolute path.
136
+
137
+ agent-cli owns all CLI flag syntax (session create/resume, model decomposition,
138
+ bypass flags, prompt delivery). This function just maps oompa opts to JSON."
139
+ [harness-kw opts]
140
+ (let [input (-> {:harness (name harness-kw)
141
+ :bypassPermissions true}
142
+ (cond->
143
+ (:model opts) (assoc :model (:model opts))
144
+ (:prompt opts) (assoc :prompt (:prompt opts))
145
+ (:session-id opts) (assoc :sessionId (:session-id opts))
146
+ (:resume? opts) (assoc :resume true)
147
+ (:cwd opts) (assoc :cwd (:cwd opts))
148
+ (:reasoning opts) (assoc :reasoning (:reasoning opts)))
149
+ (add-extra-args harness-kw opts)
150
+ json/generate-string)
151
+ {:keys [exit out err]} (process/sh ["agent-cli" "build" "--input" "-"]
152
+ {:in input :out :string :err :string})]
153
+ (when-not (zero? exit)
154
+ (throw (ex-info (str "agent-cli: " (str/trim err)) {:exit exit})))
155
+ (let [argv (:argv (json/parse-string out true))]
156
+ (vec (cons (resolve-binary! (first argv)) (rest argv))))))
157
+
158
+ (defn process-stdin
159
+ "Return the :in value for process/sh.
160
+ :close → \"\" (close stdin immediately to prevent hang).
161
+ :prompt → the prompt text (claude delivers prompt via stdin)."
162
+ [harness-kw prompt]
163
+ (let [{:keys [stdin]} (get-config harness-kw)]
164
+ (case stdin
165
+ :prompt prompt
166
+ :close "")))
167
+
168
+ ;; =============================================================================
169
+ ;; Session Management
170
+ ;; =============================================================================
171
+
172
+ (defn make-session-id
173
+ "Generate initial session-id based on harness strategy.
174
+ :uuid → random UUID string.
175
+ :extracted → nil (will be parsed from output after first run).
176
+ :implicit → nil (harness manages sessions by cwd, e.g. gemini)."
177
+ [harness-kw]
178
+ (let [{:keys [session]} (get-config harness-kw)]
179
+ (when (= session :uuid)
180
+ (str/lower-case (str (java.util.UUID/randomUUID))))))
181
+
182
+ ;; =============================================================================
183
+ ;; Output Parsing
184
+ ;; =============================================================================
185
+
186
+ (defn parse-output
187
+ "Parse agent output. For :ndjson harnesses, extracts session-id and text.
188
+ For :plain, returns output as-is.
189
+ Returns {:output string, :session-id string}."
190
+ [harness-kw raw-output session-id]
191
+ (let [{:keys [output]} (get-config harness-kw)]
192
+ (if (= output :ndjson)
193
+ (let [parsed (parse-opencode-run-output raw-output)]
194
+ {:output (or (:text parsed) raw-output)
195
+ :session-id (or (:session-id parsed) session-id)})
196
+ {:output raw-output
197
+ :session-id session-id})))
198
+
199
+ ;; =============================================================================
200
+ ;; Probe / Health Check — delegates to agent-cli check
201
+ ;; =============================================================================
202
+
203
+ (defn check-available
204
+ "Check if harness CLI binary is available on PATH via agent-cli."
205
+ [harness-kw]
206
+ (try
207
+ (let [{:keys [exit out]} (process/sh ["agent-cli" "check" (name harness-kw)]
208
+ {:out :string :err :string})]
209
+ (when (zero? exit)
210
+ (:available (json/parse-string out true))))
211
+ (catch Exception _ false)))
212
+
213
+ (defn build-probe-cmd
214
+ "Build minimal command to test if a harness+model is accessible.
215
+ Delegates to build-cmd with a '[_HIDE_TEST_] say ok' probe prompt."
216
+ [harness-kw model]
217
+ (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,17 +50,18 @@
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)
@@ -74,20 +82,33 @@
74
82
  :model (:model reviewer-config)
75
83
  :prompts (:prompts reviewer-config)})}))
76
84
 
85
+ ;; =============================================================================
86
+ ;; Stopped Event — written at swarm end
87
+ ;; =============================================================================
88
+
89
+ (defn write-stopped!
90
+ "Write the stopped event when a swarm exits cleanly."
91
+ [swarm-id reason & {:keys [error]}]
92
+ (write-json! (str (run-dir swarm-id) "/stopped.json")
93
+ {:swarm-id swarm-id
94
+ :stopped-at (str (java.time.Instant/now))
95
+ :reason (name reason)
96
+ :error error}))
97
+
77
98
  ;; =============================================================================
78
99
  ;; Review Log — written after each review round
79
100
  ;; =============================================================================
80
101
 
81
102
  (defn write-review-log!
82
- "Write a review log for one iteration of a worker.
103
+ "Write a review log for one cycle of a worker.
83
104
  Contains: verdict, round number, full reviewer output, diff file list."
84
- [swarm-id worker-id iteration round
105
+ [swarm-id worker-id cycle round
85
106
  {:keys [verdict output diff-files]}]
86
107
  (ensure-run-dirs! swarm-id)
87
- (let [filename (format "%s-i%d-r%d.json" worker-id iteration round)]
108
+ (let [filename (format "%s-c%d-r%d.json" worker-id cycle round)]
88
109
  (write-json! (str (reviews-dir swarm-id) "/" filename)
89
110
  {:worker-id worker-id
90
- :iteration iteration
111
+ :cycle cycle
91
112
  :round round
92
113
  :verdict (name verdict)
93
114
  :timestamp (str (java.time.Instant/now))
@@ -95,36 +116,30 @@
95
116
  :diff-files (vec diff-files)})))
96
117
 
97
118
  ;; =============================================================================
98
- ;; Swarm Summary — written at swarm end
119
+ ;; Cycle Log — written per cycle for real-time dashboard visibility
99
120
  ;; =============================================================================
100
121
 
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})))
122
+ (defn write-cycle-log!
123
+ "Write a cycle event log for a worker.
124
+ Contains: outcome, timing, claimed task IDs, recycled tasks, session-id.
125
+ session-id links to the Claude CLI conversation on disk for debugging.
126
+ Written at cycle end so dashboards can track progress in real-time."
127
+ [swarm-id worker-id cycle
128
+ {:keys [outcome duration-ms claimed-task-ids recycled-tasks
129
+ error-snippet review-rounds session-id]}]
130
+ (when swarm-id
131
+ (let [filename (format "%s-c%d.json" worker-id cycle)]
132
+ (write-json! (str (cycles-dir swarm-id) "/" filename)
133
+ {:worker-id worker-id
134
+ :cycle cycle
135
+ :outcome (name outcome)
136
+ :timestamp (str (java.time.Instant/now))
137
+ :duration-ms duration-ms
138
+ :claimed-task-ids (or claimed-task-ids [])
139
+ :recycled-tasks (or recycled-tasks [])
140
+ :error-snippet error-snippet
141
+ :review-rounds (or review-rounds 0)
142
+ :session-id session-id}))))
128
143
 
129
144
  ;; =============================================================================
130
145
  ;; Read helpers (for cmd-status, dashboards)
@@ -140,17 +155,17 @@
140
155
  (sort-by #(.lastModified %) >)
141
156
  (mapv #(.getName %))))))
142
157
 
143
- (defn read-run-log
144
- "Read run.json for a swarm."
158
+ (defn read-started
159
+ "Read started.json for a swarm."
145
160
  [swarm-id]
146
- (let [f (io/file (str (run-dir swarm-id) "/run.json"))]
161
+ (let [f (io/file (str (run-dir swarm-id) "/started.json"))]
147
162
  (when (.exists f)
148
163
  (json/parse-string (slurp f) true))))
149
164
 
150
- (defn read-summary
151
- "Read summary.json for a swarm."
165
+ (defn read-stopped
166
+ "Read stopped.json for a swarm. Returns nil if still running."
152
167
  [swarm-id]
153
- (let [f (io/file (str (run-dir swarm-id) "/summary.json"))]
168
+ (let [f (io/file (str (run-dir swarm-id) "/stopped.json"))]
154
169
  (when (.exists f)
155
170
  (json/parse-string (slurp f) true))))
156
171
 
@@ -163,3 +178,13 @@
163
178
  (filter #(str/ends-with? (.getName %) ".json"))
164
179
  (sort-by #(.getName %))
165
180
  (mapv (fn [f] (json/parse-string (slurp f) true)))))))
181
+
182
+ (defn list-cycles
183
+ "List all cycle logs for a swarm, sorted by filename."
184
+ [swarm-id]
185
+ (let [d (io/file (cycles-dir swarm-id))]
186
+ (when (.exists d)
187
+ (->> (.listFiles d)
188
+ (filter #(str/ends-with? (.getName %) ".json"))
189
+ (sort-by #(.getName %))
190
+ (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})
@@ -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
  []