@cxi-lmai/ci-agent-platform 3.1.2 → 3.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cxi-lmai/ci-agent-platform",
3
- "version": "3.1.2",
3
+ "version": "3.1.4",
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",
@@ -79,6 +79,18 @@ variables:
79
79
  PIPE_ORCHESTRATE: "0" # set to "1" in the schedule to run orchestrate
80
80
  PIPE_SPEC_TEMPLATE_PATH: ".gitlab/issue_templates/Spec.md"
81
81
 
82
+ # --- Agent deadline (omp harness) -----------------------------------------
83
+ # A job `timeout:` is a hard kill — the runner SIGKILLs the process tree and
84
+ # the runner script never posts its comment, metrics or marker file. So
85
+ # `pipe_run_omp` bounds the agent itself with `omp --max-time`, derived from
86
+ # GitLab's own CI_JOB_TIMEOUT minus the time already spent minus the reserve
87
+ # below, which leaves room for the post-agent steps. An overrun then takes
88
+ # the normal degraded path (failure-notice comment + metrics) instead of
89
+ # dying silently. Set PIPE_AGENT_MAX_TIME (e.g. "20m") to override the
90
+ # derivation for every job; GitHub Actions exposes no job-timeout variable,
91
+ # so there the flag is omitted and timeout-minutes stays the only bound.
92
+ PIPE_AGENT_TIME_RESERVE: "180" # seconds of the job budget kept for the post-agent steps
93
+
82
94
  # --- Build / verify --------------------------------------------------------
83
95
  PIPE_VERIFY_CMD: "" # compile + unit tests, run after a fix
84
96
  PIPE_COMPILE_CMD: "" # compile only (defaults to PIPE_VERIFY_CMD)
@@ -212,11 +224,15 @@ variables:
212
224
 
213
225
  # =============================================================================
214
226
  # review: run the automated review on a non-draft MR that changes code.
227
+ # The 45m budget matches `code` and is not generous: the skill fans out to the
228
+ # code-reviewer plus up to three specialist subagents, and observed runs on a
229
+ # real project spread from 1m to over 20m with diff size. It was 20m until
230
+ # three consecutive cap-outs on large MRs were measured in production.
215
231
  # =============================================================================
216
232
  review:
217
233
  extends: .claude-base
218
234
  stage: review
219
- timeout: 20m
235
+ timeout: 45m
220
236
  script:
221
237
  - bash "$PIPE_SCRIPTS_DIR/review.sh"
222
238
  artifacts:
@@ -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
  ;;
@@ -369,6 +369,49 @@ pipe_translate_tools_to_omp() {
369
369
  echo "${result#,}"
370
370
  }
371
371
 
372
+ # --- Agent deadline -----------------------------------------------------
373
+ # A CI job timeout is a hard kill: the runner SIGKILLs the whole process tree,
374
+ # so the runner script never reaches the steps that post the comment, write
375
+ # the metrics record and emit the marker file. An agent that overruns
376
+ # therefore leaves a red job with no output at all (observed in production:
377
+ # three review timeouts in one week, each at the cap with the agent mid-run).
378
+ # Bounding the agent itself with `omp --max-time` turns an overrun into the
379
+ # platform's normal degraded path — failure-notice comment plus metrics —
380
+ # instead of a hard kill.
381
+
382
+ pipe_agent_max_time() {
383
+ # Echoes the --max-time value to pass to omp, or nothing when no deadline
384
+ # can be derived (GitHub Actions exposes no CI_JOB_TIMEOUT equivalent;
385
+ # there the workflow's timeout-minutes stays the only bound). An explicit
386
+ # PIPE_AGENT_MAX_TIME wins and is passed through verbatim, so a project can
387
+ # set "20m" or "1h" by hand. Otherwise the budget is the job timeout, minus
388
+ # the time already spent (image pull, checkout, before_script, API prep),
389
+ # minus PIPE_AGENT_TIME_RESERVE for the post-agent steps.
390
+ if [ -n "${PIPE_AGENT_MAX_TIME:-}" ]; then
391
+ printf '%s' "$PIPE_AGENT_MAX_TIME"
392
+ return 0
393
+ fi
394
+ local budget="${CI_JOB_TIMEOUT:-}"
395
+ case "$budget" in ''|*[!0-9]*) return 0 ;; esac
396
+ local reserve="${PIPE_AGENT_TIME_RESERVE:-180}" elapsed=0 started now
397
+ # Guard the emptiness explicitly: `date -d ""` does not fail, it silently
398
+ # resolves to midnight today, which would read as a many-hour-old job.
399
+ if [ -n "${CI_JOB_STARTED_AT:-}" ]; then
400
+ started=$(date -d "$CI_JOB_STARTED_AT" +%s 2>/dev/null || true)
401
+ if [ -n "$started" ]; then
402
+ now=$(date +%s)
403
+ elapsed=$(( now - started ))
404
+ [ "$elapsed" -lt 0 ] && elapsed=0
405
+ fi
406
+ fi
407
+ local left=$(( budget - elapsed - reserve ))
408
+ # A job already out of budget gets no flag: let the agent start and be cut
409
+ # by the runner exactly as it was before this function existed, rather than
410
+ # be killed one second in with a nonsensical deadline.
411
+ [ "$left" -lt 60 ] && return 0
412
+ printf '%s' "$left"
413
+ }
414
+
372
415
  pipe_run_omp() {
373
416
  # $1 = agent label for metrics, $2 = skill invocation (e.g. "/review-mr"),
374
417
  # $3 = allowed tools in Claude Code naming (optional, translated
@@ -377,6 +420,14 @@ pipe_run_omp() {
377
420
  # frontmatter), stdin = extra prompt (optional, usually empty).
378
421
  local label="$1" skill="$2" tools="${3:-Agent,Read,Write,Edit,Glob,Grep,Bash}" model="${4:-}"
379
422
  local omp_tools; omp_tools=$(pipe_translate_tools_to_omp "$tools")
423
+ # Bound the run below the job's own hard kill (see pipe_agent_max_time).
424
+ local max_time; max_time=$(pipe_agent_max_time)
425
+ local -a max_time_args=()
426
+ local max_time_txt=""
427
+ if [ -n "$max_time" ]; then
428
+ max_time_args=(--max-time "$max_time")
429
+ max_time_txt=$(printf ' --max-time %q' "$max_time")
430
+ fi
380
431
  pipe_require_harness omp
381
432
  local stdin_file; stdin_file=$(mktemp)
382
433
  cat > "$stdin_file" || true
@@ -402,12 +453,14 @@ pipe_run_omp() {
402
453
  printf 'cd %q\n' "$PWD"
403
454
  printf 'source %q\n' "$SCRIPT_LIB_DIR/usage-capture-omp.sh"
404
455
  printf 'export PIPE_CAPTURE_MODEL=%q\n' "$model"
456
+ # The %s after `--` is the optional " --max-time <d>" fragment, already
457
+ # shell-quoted; it collapses to nothing when no deadline was derived.
405
458
  if [ -n "$model" ]; then
406
- printf 'capture_omp %q -- --yolo --no-session --no-title --model %q --tools %q -p %q < %q\n' \
407
- "$label" "$model" "$omp_tools" "$skill" "$stdin_file"
459
+ printf 'capture_omp %q --%s --yolo --no-session --no-title --model %q --tools %q -p %q < %q\n' \
460
+ "$label" "$max_time_txt" "$model" "$omp_tools" "$skill" "$stdin_file"
408
461
  else
409
- printf 'capture_omp %q -- --yolo --no-session --no-title --tools %q -p %q < %q\n' \
410
- "$label" "$omp_tools" "$skill" "$stdin_file"
462
+ printf 'capture_omp %q --%s --yolo --no-session --no-title --tools %q -p %q < %q\n' \
463
+ "$label" "$max_time_txt" "$omp_tools" "$skill" "$stdin_file"
411
464
  fi
412
465
  } > "$runner"
413
466
  chmod +x "$runner"
@@ -420,9 +473,9 @@ pipe_run_omp() {
420
473
  pipe_scrub_agent_secrets
421
474
  export PIPE_CAPTURE_MODEL="$model"
422
475
  if [ -n "$model" ]; then
423
- capture_omp "$label" -- --yolo --no-session --no-title --model "$model" --tools "$omp_tools" -p "$skill" < "$stdin_file"
476
+ capture_omp "$label" -- "${max_time_args[@]}" --yolo --no-session --no-title --model "$model" --tools "$omp_tools" -p "$skill" < "$stdin_file"
424
477
  else
425
- capture_omp "$label" -- --yolo --no-session --no-title --tools "$omp_tools" -p "$skill" < "$stdin_file"
478
+ capture_omp "$label" -- "${max_time_args[@]}" --yolo --no-session --no-title --tools "$omp_tools" -p "$skill" < "$stdin_file"
426
479
  fi
427
480
  ) || true
428
481
  fi
@@ -186,9 +186,13 @@ MRS_VIEW=$(jq -c \
186
186
  elif ($body | test($reintroduce; "i")) then "reintroduces"
187
187
  else null end
188
188
  ),
189
+ # match/2 with "g", not scan/2: a Debian bookworm based runner image
190
+ # ships jq 1.6, which has no scan/2 and fails the whole program with
191
+ # a compile error.
189
192
  references: [
190
193
  $body
191
- | scan("(?:" + $revert + "|" + $regression + "|" + $reintroduce + ")[[:space:]]*[!#]?([0-9]+)"; "i")[]?
194
+ | match("(?:" + $revert + "|" + $regression + "|" + $reintroduce + ")[[:space:]]*[!#]?([0-9]+)"; "gi")
195
+ | .captures[].string
192
196
  | select(. != null)
193
197
  | tonumber
194
198
  ]
@@ -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