@cxi-lmai/ci-agent-platform 3.1.1 → 3.1.3

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.
package/README.md CHANGED
@@ -144,6 +144,13 @@ picked, not both. Claude-harness model usage is billed by Anthropic, while
144
144
  omp-harness model usage is billed by OpenRouter. GitLab only for now; GitHub
145
145
  Actions omp support is not shipped yet.
146
146
 
147
+ `omp` is a Bun program, so the GitLab template's `before_script` installs
148
+ `bun` next to it (`npm install -g bun`) — the default `node:22-bookworm` image
149
+ ships no Bun runtime, and without it every agent call fails while the job
150
+ still reports success. A custom `PIPE_CI_IMAGE` needs nothing beyond node +
151
+ npm for that install to work. If the harness binary cannot execute, the job
152
+ now fails immediately instead of going green with no review.
153
+
147
154
  ### What it costs
148
155
 
149
156
  Every pipeline job spends paid model usage, so the defaults are deliberately
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cxi-lmai/ci-agent-platform",
3
- "version": "3.1.1",
3
+ "version": "3.1.3",
4
4
  "description": "Autonomous dev pipeline on plain GitLab CI or GitHub Actions, driven by Claude Code. A labeled issue goes in, an open merge request comes out.",
5
5
  "keywords": [
6
6
  "claude",
@@ -31,8 +31,9 @@ variables:
31
31
  PIPE_SCRIPTS_DIR: ".claude-pipeline/scripts"
32
32
 
33
33
  # --- CI image (see ci-templates decision note) -----------------------------
34
- # Must provide: node + npm, git, curl. jq and claude-code are installed in
35
- # before_script when missing. The runner scripts talk to the GitLab API with
34
+ # Must provide: node + npm, git, curl. jq, claude-code, and (when
35
+ # PIPE_HARNESS=omp) bun + omp are installed in before_script when missing.
36
+ # The runner scripts talk to the GitLab API with
36
37
  # curl and jq directly, so no glab is needed in the image; glab is only used
37
38
  # interactively, by the install wizard.
38
39
  # Python caveat (E2E finding N-7): node:22-bookworm ships python3 but NOT
@@ -162,7 +163,7 @@ variables:
162
163
 
163
164
  # --- Hidden base job: shared config + runtime variable mapping ---------------
164
165
  # Maps GitLab's CI_ runtime variables onto the PIPE_ names the scripts read, and
165
- # installs claude-code if the image does not ship it.
166
+ # installs the selected harness (and its runtime) if the image does not ship it.
166
167
  .claude-base:
167
168
  image: $PIPE_CI_IMAGE
168
169
  variables:
@@ -193,6 +194,12 @@ variables:
193
194
  fi
194
195
  - |
195
196
  if [ "$PIPE_HARNESS" = "omp" ]; then
197
+ # omp is a Bun program (`#!/usr/bin/env bun`, engines.bun >= 1.3.14)
198
+ # and node:22-bookworm ships no bun, so the installed `omp` shim is
199
+ # unrunnable without it. The `bun` npm package postinstalls the real
200
+ # binary, so no curl|bash and no image change is needed. Deliberately
201
+ # no `|| true`: a missing harness runtime must fail the job loudly.
202
+ command -v bun >/dev/null 2>&1 || npm install -g bun
196
203
  command -v omp >/dev/null 2>&1 || npm install -g @oh-my-pi/pi-coding-agent
197
204
  else
198
205
  command -v claude >/dev/null 2>&1 || npm install -g @anthropic-ai/claude-code
@@ -120,6 +120,35 @@ _gli_issue_state() {
120
120
  curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1" | jq -r '.state // "opened"'
121
121
  }
122
122
 
123
+ _gli_merged_closer() {
124
+ # $1=iid. Prints the newest merged MR that closes the issue as compact JSON,
125
+ # or nothing. A later reopen invalidates an earlier merge.
126
+ local response closer events merged_at
127
+ response=$(curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
128
+ "$(_gli_api)/issues/$1/closed_by?per_page=100") || return 1
129
+ closer=$(jq -c --arg branch "$PIPE_TARGET_BRANCH" '
130
+ [.[] | select(.state == "merged" and .merged_at != null and .target_branch == $branch)]
131
+ | max_by(.merged_at)
132
+ | if . == null then empty else
133
+ {kind: "MR",
134
+ reference: (.references.relative // .reference // ("!" + (.iid | tostring))),
135
+ url: .web_url,
136
+ merged_at: .merged_at}
137
+ end
138
+ ' <<< "$response") || return 1
139
+ [ -n "$closer" ] || return 0
140
+
141
+ events=$(curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
142
+ "$(_gli_api)/issues/$1/resource_state_events?per_page=100") || return 1
143
+ merged_at=$(jq -r '.merged_at' <<< "$closer")
144
+ if jq -e --arg merged_at "$merged_at" \
145
+ 'any(.[]; .state == "opened" and (.created_at // "") > $merged_at)' \
146
+ <<< "$events" >/dev/null; then
147
+ return 0
148
+ fi
149
+ printf '%s\n' "$closer"
150
+ }
151
+
123
152
  _gli_blockers_open_count() {
124
153
  # $1=iid. Count of open issues that block this one (native links).
125
154
  curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1/links" \
@@ -168,8 +197,9 @@ _gli_link_blocked_by() {
168
197
  }
169
198
 
170
199
  _gli_set_issue_labels() {
171
- # $1=iid $2=add(comma) $3=remove(comma). Returns HTTP-independent (best effort).
172
- curl -s -X PUT -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
200
+ # $1=iid $2=add(comma) $3=remove(comma). HTTP failures must stop dispatch.
201
+ curl --fail-with-body -s -X PUT \
202
+ -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
173
203
  "$(_gli_api)/issues/$1" \
174
204
  -d "$(jq -n --arg a "$2" --arg r "$3" '{add_labels:$a, remove_labels:$r}')" > /dev/null
175
205
  }
@@ -310,10 +340,57 @@ _ghi_issue_meta() {
310
340
 
311
341
  _ghi_issue_state() {
312
342
  # Prints "opened" or "closed" (normalised to the GitLab vocabulary).
313
- local s; s=$(gh api "repos/$PIPE_REPO/issues/$1" --jq '.state // "open"' 2>/dev/null || echo open)
343
+ local s
344
+ s=$(gh api "repos/$PIPE_REPO/issues/$1" --jq '.state // "open"' 2>/dev/null) || return 1
314
345
  [ "$s" = "closed" ] && echo closed || echo opened
315
346
  }
316
347
 
348
+ _ghi_merged_closer() {
349
+ # $1=iid. GitHub's GraphQL closing-reference connection is exact for both
350
+ # keyword and manually linked PRs. One query also carries reopen events.
351
+ local owner="${PIPE_REPO%%/*}" repo="${PIPE_REPO#*/}" response
352
+ response=$(gh api graphql \
353
+ -f "owner=$owner" -f "name=$repo" -F "number=$1" \
354
+ -f query='
355
+ query($owner: String!, $name: String!, $number: Int!) {
356
+ repository(owner: $owner, name: $name) {
357
+ issue(number: $number) {
358
+ closedByPullRequestsReferences(first: 100, includeClosedPrs: true) {
359
+ nodes {
360
+ number
361
+ url
362
+ merged
363
+ mergedAt
364
+ baseRefName
365
+ }
366
+ }
367
+ timelineItems(last: 100, itemTypes: [REOPENED_EVENT]) {
368
+ nodes {
369
+ ... on ReopenedEvent {
370
+ createdAt
371
+ }
372
+ }
373
+ }
374
+ }
375
+ }
376
+ }
377
+ ') || return 1
378
+ jq -c --arg branch "$PIPE_TARGET_BRANCH" '
379
+ .data.repository.issue as $issue
380
+ | ([$issue.closedByPullRequestsReferences.nodes[]
381
+ | select(.merged == true and .mergedAt != null and .baseRefName == $branch)]
382
+ | max_by(.mergedAt)) as $closer
383
+ | ([$issue.timelineItems.nodes[]?.createdAt] | max // "") as $reopened_at
384
+ | if $closer != null and ($reopened_at == "" or $closer.mergedAt > $reopened_at)
385
+ then {kind: "PR",
386
+ reference: ("#" + ($closer.number | tostring)),
387
+ url: $closer.url,
388
+ merged_at: $closer.mergedAt}
389
+ else empty
390
+ end
391
+ ' <<< "$response"
392
+ }
393
+
317
394
  _ghi_link_blocked_by() {
318
395
  # GitHub has no native blocking link. Persist the real issue number in the
319
396
  # dependent issue body after all local decomposition keys have been mapped.
@@ -351,8 +428,14 @@ _ghi_set_issue_labels() {
351
428
  # $1=iid $2=add(comma) $3=remove(comma)
352
429
  local l
353
430
  local IFS=','
354
- for l in $2; do [ -n "$l" ] && gh api -X POST "repos/$PIPE_REPO/issues/$1/labels" -f "labels[]=$l" > /dev/null 2>&1 || true; done
355
- for l in $3; do [ -n "$l" ] && gh api -X DELETE "repos/$PIPE_REPO/issues/$1/labels/$l" > /dev/null 2>&1 || true; done
431
+ for l in $2; do
432
+ [ -z "$l" ] || gh api -X POST "repos/$PIPE_REPO/issues/$1/labels" \
433
+ -f "labels[]=$l" > /dev/null 2>&1 || return 1
434
+ done
435
+ for l in $3; do
436
+ [ -z "$l" ] || gh api -X DELETE "repos/$PIPE_REPO/issues/$1/labels/$l" \
437
+ > /dev/null 2>&1 || return 1
438
+ done
356
439
  }
357
440
 
358
441
  _ghi_close_issue() {
@@ -428,6 +511,7 @@ issue_notes() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_notes "
428
511
  issue_body() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_body "$1" ;; github) _ghi_issue_body "$1" ;; esac; }
429
512
  issue_meta() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_meta "$1" ;; github) _ghi_issue_meta "$1" ;; esac; }
430
513
  issue_state() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_state "$1" ;; github) _ghi_issue_state "$1" ;; esac; }
514
+ issue_merged_closer() { case "$PIPE_PLATFORM" in gitlab) _gli_merged_closer "$1" ;; github) _ghi_merged_closer "$1" ;; esac; }
431
515
  issue_create_file() { case "$PIPE_PLATFORM" in gitlab) _gli_create_issue_file "$1" "$2" "$3" ;; github) _ghi_create_issue_file "$1" "$2" "$3" ;; esac; }
432
516
  issue_set_labels() { case "$PIPE_PLATFORM" in gitlab) _gli_set_issue_labels "$1" "$2" "$3" ;; github) _ghi_set_issue_labels "$1" "$2" "$3" ;; esac; }
433
517
  issue_close() { case "$PIPE_PLATFORM" in gitlab) _gli_close_issue "$1" ;; github) _ghi_close_issue "$1" ;; esac; }
@@ -442,12 +526,13 @@ issue_blockers_open_count() {
442
526
  case "$PIPE_PLATFORM" in
443
527
  gitlab) _gli_blockers_open_count "$1" ;;
444
528
  github)
445
- local body refs n open=0
446
- body=$(_ghi_issue_body "$1")
529
+ local body refs n state open=0
530
+ body=$(_ghi_issue_body "$1") || return 1
447
531
  # Match "Blocked by #12" / "blocked by #12, #13" case-insensitively.
448
532
  refs=$(printf '%s' "$body" | grep -ioE 'blocked by[^\n]*' | grep -oE '#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
449
533
  for n in $refs; do
450
- [ "$(_ghi_issue_state "$n")" = "opened" ] && open=$((open + 1))
534
+ state=$(_ghi_issue_state "$n") || return 1
535
+ [ "$state" = "opened" ] && open=$((open + 1))
451
536
  done
452
537
  echo "$open"
453
538
  ;;
@@ -246,12 +246,39 @@ pipe_scrub_agent_secrets() {
246
246
  done < <(compgen -e | while IFS= read -r v; do pipe_agent_secret_var "$v" && echo "$v"; done)
247
247
  }
248
248
 
249
+ # --- Harness preflight ------------------------------------------------------
250
+ # Every pipe_run_* invocation ends in `|| true`, so a harness binary that
251
+ # exists but cannot execute used to produce a green job with no review, no
252
+ # marker file and one line on stderr (issue #1: `omp` is a Bun program and the
253
+ # default node:22-bookworm image ships no bun, so `command -v omp` succeeded
254
+ # while every run failed with "/usr/bin/env: 'bun': No such file or
255
+ # directory"). Executing the binary once, before the tolerant invocation,
256
+ # converts any harness-runtime breakage into an immediate job failure.
257
+
258
+ pipe_require_harness() {
259
+ # $1 = harness binary name (claude|omp). Exits the job on failure: a
260
+ # non-runnable harness means no agent can run, so there is nothing left for
261
+ # the caller to recover from.
262
+ local bin="$1" out
263
+ if ! command -v "$bin" >/dev/null 2>&1; then
264
+ pipe_log "FATAL: harness binary '$bin' not found on PATH (PIPE_HARNESS=${PIPE_HARNESS:-claude}); install it in before_script"
265
+ exit 1
266
+ fi
267
+ if ! out=$("$bin" --version 2>&1); then
268
+ pipe_log "FATAL: harness binary '$bin' ($(command -v "$bin")) cannot execute: '$bin --version' failed"
269
+ printf '%s\n' "$out" >&2
270
+ pipe_log "omp requires bun on PATH (npm install -g bun); claude requires node"
271
+ exit 1
272
+ fi
273
+ }
274
+
249
275
  pipe_run_claude() {
250
276
  # $1 = agent label for metrics, $2 = skill invocation (e.g. "/review-mr"),
251
277
  # $3 = allowed tools (optional), $4 = model for the main loop (optional,
252
278
  # e.g. $PIPE_MODEL_REVIEW; subagents keep their frontmatter models),
253
279
  # stdin = extra prompt (optional, usually empty)
254
280
  local label="$1" skill="$2" tools="${3:-Agent,Read,Write,Edit,Glob,Grep,Bash}" model="${4:-}"
281
+ pipe_require_harness claude
255
282
  local stdin_file; stdin_file=$(mktemp)
256
283
  cat > "$stdin_file" || true
257
284
 
@@ -350,6 +377,7 @@ pipe_run_omp() {
350
377
  # frontmatter), stdin = extra prompt (optional, usually empty).
351
378
  local label="$1" skill="$2" tools="${3:-Agent,Read,Write,Edit,Glob,Grep,Bash}" model="${4:-}"
352
379
  local omp_tools; omp_tools=$(pipe_translate_tools_to_omp "$tools")
380
+ pipe_require_harness omp
353
381
  local stdin_file; stdin_file=$(mktemp)
354
382
  cat > "$stdin_file" || true
355
383
 
@@ -87,6 +87,100 @@ emit_decision_metric() {
87
87
  '{issue_iid: $i, verdict: $v, decision_type: $dt, reason: $r} + $x')"
88
88
  }
89
89
 
90
+ dispatch_restore_ready() {
91
+ # $1=iid. Best-effort recovery back to ready after a mid-dispatch failure.
92
+ # Nothing further can be attempted from this runner if this also fails, so
93
+ # it logs loudly and meters a terminal, distinctly countable label-error.
94
+ if issue_set_labels "$1" "$PIPE_LABEL_READY" "$PIPE_LABEL_WIP"; then
95
+ return 0
96
+ fi
97
+ pipe_log " ERROR: could not restore $PIPE_LABEL_READY on #$1; lifecycle labels may be inconsistent, manual check required"
98
+ emit_decision_metric "$1" skipped label-error "Failed to restore ready after a dispatch-time error"
99
+ return 1
100
+ }
101
+
102
+ dispatch_queued_issue() {
103
+ # $1=iid $2=branch. Claim atomically, then re-check repository state at the
104
+ # last possible point before spending a coder invocation. Every subsequent
105
+ # label transition is checked: a silently failed transition is exactly how
106
+ # an issue gets stuck in wip or loses its ready label forever.
107
+ local iid="$1" branch="$2" open closer kind reference url reason comment_file
108
+
109
+ if ! issue_set_labels "$iid" "$PIPE_LABEL_WIP" "$PIPE_LABEL_READY"; then
110
+ pipe_log " ERROR: could not claim #$iid, skipping to avoid a double trigger"
111
+ emit_decision_metric "$iid" skipped claim-failed "Could not atomically claim the ready issue"
112
+ return 0
113
+ fi
114
+
115
+ if ! open=$(issue_blockers_open_count "$iid"); then
116
+ reason="Could not verify whether issue prerequisites are still open"
117
+ pipe_log " ERROR: $reason for #$iid; restoring $PIPE_LABEL_READY"
118
+ dispatch_restore_ready "$iid" || return 0
119
+ emit_decision_metric "$iid" deferred blocker-validation-error "$reason"
120
+ return 0
121
+ fi
122
+ if [ "${open:-1}" != "0" ]; then
123
+ reason="Issue has ${open:-unknown} open prerequisite(s)"
124
+ if issue_set_labels "$iid" "$PIPE_LABEL_BLOCKED" "$PIPE_LABEL_WIP,$PIPE_LABEL_READY"; then
125
+ pipe_log " #$iid returned to $PIPE_LABEL_BLOCKED: $reason"
126
+ emit_decision_metric "$iid" deferred blocked-prerequisite "$reason"
127
+ else
128
+ pipe_log " ERROR: could not move #$iid to $PIPE_LABEL_BLOCKED, restoring $PIPE_LABEL_READY: $reason"
129
+ dispatch_restore_ready "$iid" || return 0
130
+ emit_decision_metric "$iid" deferred blocked-transition-error "$reason"
131
+ fi
132
+ return 0
133
+ fi
134
+
135
+ if ! closer=$(issue_merged_closer "$iid"); then
136
+ reason="Could not verify whether a closing MR/PR has already merged"
137
+ pipe_log " ERROR: $reason for #$iid; restoring $PIPE_LABEL_READY"
138
+ dispatch_restore_ready "$iid" || return 0
139
+ emit_decision_metric "$iid" deferred validation-error "$reason"
140
+ return 0
141
+ fi
142
+
143
+ if [ -n "$closer" ]; then
144
+ kind=$(jq -r '.kind' <<< "$closer")
145
+ reference=$(jq -r '.reference' <<< "$closer")
146
+ url=$(jq -r '.url' <<< "$closer")
147
+ reason="$kind $reference already merged into $PIPE_TARGET_BRANCH"
148
+ comment_file="$PIPE_CONTEXT_DIR/orchestrator-already-merged-$iid.md"
149
+ {
150
+ printf '**Dispatch skipped:** %s %s has already merged into `%s`.\n\n' \
151
+ "$kind" "$reference" "$PIPE_TARGET_BRANCH"
152
+ printf '%s\n\n' "$url"
153
+ printf 'The pipeline removed `%s`; apply it again only if more work is required.\n' \
154
+ "$PIPE_LABEL_READY"
155
+ } > "$comment_file"
156
+ # Comment before the final label removal: if the comment never lands,
157
+ # the ready label must survive so the skip is retried and the change is
158
+ # named next time, instead of silently vanishing off the board.
159
+ if ! issue_comment_file "$iid" "$comment_file"; then
160
+ pipe_log " ERROR: could not comment on #$iid before skipping; restoring $PIPE_LABEL_READY for retry"
161
+ dispatch_restore_ready "$iid" || return 0
162
+ emit_decision_metric "$iid" deferred comment-error "$reason"
163
+ return 0
164
+ fi
165
+ pipe_log " skipping coder for #$iid: $reason ($url)"
166
+ if ! issue_set_labels "$iid" "" "$PIPE_LABEL_WIP,$PIPE_LABEL_READY"; then
167
+ pipe_log " ERROR: could not clear lifecycle labels on #$iid after commenting; restoring $PIPE_LABEL_READY"
168
+ dispatch_restore_ready "$iid"
169
+ fi
170
+ emit_decision_metric "$iid" skipped already-merged "$reason" \
171
+ "$(jq -n --arg kind "$kind" --arg reference "$reference" --arg url "$url" \
172
+ '{closer_kind: $kind, closer_reference: $reference, closer_url: $url}')"
173
+ return 0
174
+ fi
175
+
176
+ if code_trigger "$iid" "$branch"; then
177
+ pipe_log " triggered coder for #$iid on $branch"
178
+ else
179
+ pipe_log " ERROR: trigger failed for #$iid, reverting label"
180
+ dispatch_restore_ready "$iid"
181
+ fi
182
+ }
183
+
90
184
  while IFS= read -r ISSUE; do
91
185
  [ "${#QUEUE[@]}" -ge "$PIPE_CODER_CAP" ] && { pipe_log "Coder cap ($PIPE_CODER_CAP) reached, deferring the rest"; break; }
92
186
 
@@ -297,16 +391,5 @@ pipe_log "Triggering ${#QUEUE[@]} coder run(s)"
297
391
  for ITEM in "${QUEUE[@]}"; do
298
392
  IID="${ITEM%%:*}"
299
393
  BRANCH="${ITEM#*:}"
300
- # Atomic claim: relabel ready -> wip first, so a concurrent orchestrator does
301
- # not double-trigger. Revert on trigger failure.
302
- if issue_set_labels "$IID" "$PIPE_LABEL_WIP" "$PIPE_LABEL_READY"; then
303
- if code_trigger "$IID" "$BRANCH"; then
304
- pipe_log " triggered coder for #$IID on $BRANCH"
305
- else
306
- pipe_log " ERROR: trigger failed for #$IID, reverting label"
307
- issue_set_labels "$IID" "$PIPE_LABEL_READY" "$PIPE_LABEL_WIP"
308
- fi
309
- else
310
- pipe_log " ERROR: could not claim #$IID, skipping to avoid a double trigger"
311
- fi
394
+ dispatch_queued_issue "$IID" "$BRANCH"
312
395
  done