shipwright-cli 2.2.0 → 2.2.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.
Files changed (120) hide show
  1. package/README.md +15 -16
  2. package/config/policy.schema.json +104 -29
  3. package/docs/AGI-PLATFORM-PLAN.md +11 -7
  4. package/docs/AGI-WHATS-NEXT.md +26 -20
  5. package/docs/README.md +2 -0
  6. package/package.json +1 -1
  7. package/scripts/check-version-consistency.sh +72 -0
  8. package/scripts/lib/daemon-adaptive.sh +610 -0
  9. package/scripts/lib/daemon-dispatch.sh +489 -0
  10. package/scripts/lib/daemon-failure.sh +387 -0
  11. package/scripts/lib/daemon-patrol.sh +1113 -0
  12. package/scripts/lib/daemon-poll.sh +1202 -0
  13. package/scripts/lib/daemon-state.sh +550 -0
  14. package/scripts/lib/daemon-triage.sh +490 -0
  15. package/scripts/lib/helpers.sh +81 -1
  16. package/scripts/lib/pipeline-detection.sh +278 -0
  17. package/scripts/lib/pipeline-github.sh +196 -0
  18. package/scripts/lib/pipeline-intelligence.sh +1706 -0
  19. package/scripts/lib/pipeline-quality-checks.sh +1054 -0
  20. package/scripts/lib/pipeline-quality.sh +11 -0
  21. package/scripts/lib/pipeline-stages.sh +2508 -0
  22. package/scripts/lib/pipeline-state.sh +529 -0
  23. package/scripts/sw +26 -4
  24. package/scripts/sw-activity.sh +1 -1
  25. package/scripts/sw-adaptive.sh +2 -2
  26. package/scripts/sw-adversarial.sh +1 -1
  27. package/scripts/sw-architecture-enforcer.sh +1 -1
  28. package/scripts/sw-auth.sh +1 -1
  29. package/scripts/sw-autonomous.sh +1 -1
  30. package/scripts/sw-changelog.sh +1 -1
  31. package/scripts/sw-checkpoint.sh +1 -1
  32. package/scripts/sw-ci.sh +1 -1
  33. package/scripts/sw-cleanup.sh +1 -1
  34. package/scripts/sw-code-review.sh +1 -1
  35. package/scripts/sw-connect.sh +1 -1
  36. package/scripts/sw-context.sh +1 -1
  37. package/scripts/sw-cost.sh +1 -1
  38. package/scripts/sw-daemon.sh +52 -4816
  39. package/scripts/sw-dashboard.sh +1 -1
  40. package/scripts/sw-db.sh +1 -1
  41. package/scripts/sw-decompose.sh +1 -1
  42. package/scripts/sw-deps.sh +1 -1
  43. package/scripts/sw-developer-simulation.sh +1 -1
  44. package/scripts/sw-discovery.sh +1 -1
  45. package/scripts/sw-doc-fleet.sh +1 -1
  46. package/scripts/sw-docs-agent.sh +1 -1
  47. package/scripts/sw-docs.sh +1 -1
  48. package/scripts/sw-doctor.sh +42 -1
  49. package/scripts/sw-dora.sh +1 -1
  50. package/scripts/sw-durable.sh +1 -1
  51. package/scripts/sw-e2e-orchestrator.sh +1 -1
  52. package/scripts/sw-eventbus.sh +1 -1
  53. package/scripts/sw-feedback.sh +1 -1
  54. package/scripts/sw-fix.sh +1 -1
  55. package/scripts/sw-fleet-discover.sh +1 -1
  56. package/scripts/sw-fleet-viz.sh +3 -3
  57. package/scripts/sw-fleet.sh +1 -1
  58. package/scripts/sw-github-app.sh +1 -1
  59. package/scripts/sw-github-checks.sh +1 -1
  60. package/scripts/sw-github-deploy.sh +1 -1
  61. package/scripts/sw-github-graphql.sh +1 -1
  62. package/scripts/sw-guild.sh +1 -1
  63. package/scripts/sw-heartbeat.sh +1 -1
  64. package/scripts/sw-hygiene.sh +1 -1
  65. package/scripts/sw-incident.sh +1 -1
  66. package/scripts/sw-init.sh +1 -1
  67. package/scripts/sw-instrument.sh +1 -1
  68. package/scripts/sw-intelligence.sh +1 -1
  69. package/scripts/sw-jira.sh +1 -1
  70. package/scripts/sw-launchd.sh +1 -1
  71. package/scripts/sw-linear.sh +1 -1
  72. package/scripts/sw-logs.sh +1 -1
  73. package/scripts/sw-loop.sh +1 -1
  74. package/scripts/sw-memory.sh +1 -1
  75. package/scripts/sw-mission-control.sh +1 -1
  76. package/scripts/sw-model-router.sh +1 -1
  77. package/scripts/sw-otel.sh +4 -4
  78. package/scripts/sw-oversight.sh +1 -1
  79. package/scripts/sw-pipeline-composer.sh +1 -1
  80. package/scripts/sw-pipeline-vitals.sh +1 -1
  81. package/scripts/sw-pipeline.sh +23 -56
  82. package/scripts/sw-pipeline.sh.mock +7 -0
  83. package/scripts/sw-pm.sh +1 -1
  84. package/scripts/sw-pr-lifecycle.sh +1 -1
  85. package/scripts/sw-predictive.sh +1 -1
  86. package/scripts/sw-prep.sh +1 -1
  87. package/scripts/sw-ps.sh +1 -1
  88. package/scripts/sw-public-dashboard.sh +1 -1
  89. package/scripts/sw-quality.sh +1 -1
  90. package/scripts/sw-reaper.sh +1 -1
  91. package/scripts/sw-recruit.sh +9 -1
  92. package/scripts/sw-regression.sh +1 -1
  93. package/scripts/sw-release-manager.sh +1 -1
  94. package/scripts/sw-release.sh +1 -1
  95. package/scripts/sw-remote.sh +1 -1
  96. package/scripts/sw-replay.sh +1 -1
  97. package/scripts/sw-retro.sh +1 -1
  98. package/scripts/sw-scale.sh +8 -5
  99. package/scripts/sw-security-audit.sh +1 -1
  100. package/scripts/sw-self-optimize.sh +158 -7
  101. package/scripts/sw-session.sh +1 -1
  102. package/scripts/sw-setup.sh +1 -1
  103. package/scripts/sw-standup.sh +3 -3
  104. package/scripts/sw-status.sh +1 -1
  105. package/scripts/sw-strategic.sh +1 -1
  106. package/scripts/sw-stream.sh +8 -2
  107. package/scripts/sw-swarm.sh +7 -10
  108. package/scripts/sw-team-stages.sh +1 -1
  109. package/scripts/sw-templates.sh +1 -1
  110. package/scripts/sw-testgen.sh +1 -1
  111. package/scripts/sw-tmux-pipeline.sh +1 -1
  112. package/scripts/sw-tmux.sh +1 -1
  113. package/scripts/sw-trace.sh +1 -1
  114. package/scripts/sw-tracker.sh +24 -6
  115. package/scripts/sw-triage.sh +1 -1
  116. package/scripts/sw-upgrade.sh +1 -1
  117. package/scripts/sw-ux.sh +1 -1
  118. package/scripts/sw-webhook.sh +1 -1
  119. package/scripts/sw-widgets.sh +1 -1
  120. package/scripts/sw-worktree.sh +1 -1
@@ -0,0 +1,489 @@
1
+ # daemon-dispatch.sh — Spawn, reap, on_success (for sw-daemon.sh)
2
+ # Source from sw-daemon.sh. Requires state, failure, helpers.
3
+ [[ -n "${_DAEMON_DISPATCH_LOADED:-}" ]] && return 0
4
+ _DAEMON_DISPATCH_LOADED=1
5
+
6
+ # ─── Org-Wide Repo Management ─────────────────────────────────────────────
7
+
8
+ daemon_ensure_repo() {
9
+ local owner="$1" repo="$2"
10
+ local repo_dir="$DAEMON_DIR/repos/${owner}/${repo}"
11
+
12
+ if [[ -d "$repo_dir/.git" ]]; then
13
+ # Pull latest
14
+ (cd "$repo_dir" && git pull --ff-only 2>/dev/null) || {
15
+ daemon_log WARN "Failed to update ${owner}/${repo} — using existing clone"
16
+ }
17
+ else
18
+ mkdir -p "$DAEMON_DIR/repos/${owner}"
19
+ if ! git clone --depth=1 "https://github.com/${owner}/${repo}.git" "$repo_dir" 2>/dev/null; then
20
+ daemon_log ERROR "Failed to clone ${owner}/${repo}"
21
+ return 1
22
+ fi
23
+ daemon_log INFO "Cloned ${owner}/${repo} to ${repo_dir}"
24
+ fi
25
+
26
+ echo "$repo_dir"
27
+ }
28
+
29
+ # ─── Spawn Pipeline ─────────────────────────────────────────────────────────
30
+
31
+ daemon_spawn_pipeline() {
32
+ local issue_num="$1"
33
+ local issue_title="${2:-}"
34
+ local repo_full_name="${3:-}" # owner/repo (org mode only)
35
+ shift 3 2>/dev/null || true
36
+ local extra_pipeline_args=("$@") # Optional extra args passed to sw-pipeline.sh
37
+
38
+ daemon_log INFO "Spawning pipeline for issue #${issue_num}: ${issue_title}"
39
+
40
+ # ── Issue decomposition (if decomposer available) ──
41
+ local decompose_script="${SCRIPT_DIR}/sw-decompose.sh"
42
+ if [[ -x "$decompose_script" && "$NO_GITHUB" != "true" ]]; then
43
+ local decompose_result=""
44
+ decompose_result=$("$decompose_script" auto "$issue_num" 2>/dev/null) || true
45
+ if [[ "$decompose_result" == *"decomposed"* ]]; then
46
+ daemon_log INFO "Issue #${issue_num} decomposed into subtasks — skipping pipeline"
47
+ # Remove the shipwright label so decomposed parent doesn't re-queue
48
+ gh issue edit "$issue_num" --remove-label "shipwright" 2>/dev/null || true
49
+ return 0
50
+ fi
51
+ fi
52
+
53
+ # Extract goal text from issue (title + first line of body)
54
+ local issue_goal="$issue_title"
55
+ if [[ "$NO_GITHUB" != "true" ]]; then
56
+ local issue_body_first
57
+ issue_body_first=$(gh issue view "$issue_num" --json body --jq '.body' 2>/dev/null | head -3 | tr '\n' ' ' | cut -c1-200 || true)
58
+ if [[ -n "$issue_body_first" ]]; then
59
+ issue_goal="${issue_title}: ${issue_body_first}"
60
+ fi
61
+ fi
62
+
63
+ # ── Predictive risk assessment (if enabled) ──
64
+ if [[ "${PREDICTION_ENABLED:-false}" == "true" ]] && type predict_pipeline_risk &>/dev/null 2>&1; then
65
+ local issue_json_for_pred=""
66
+ if [[ "$NO_GITHUB" != "true" ]]; then
67
+ issue_json_for_pred=$(gh issue view "$issue_num" --json number,title,body,labels 2>/dev/null || echo "")
68
+ fi
69
+ if [[ -n "$issue_json_for_pred" ]]; then
70
+ local risk_result
71
+ risk_result=$(predict_pipeline_risk "$issue_json_for_pred" "" 2>/dev/null || echo "")
72
+ if [[ -n "$risk_result" ]]; then
73
+ local overall_risk
74
+ overall_risk=$(echo "$risk_result" | jq -r '.overall_risk // 50' 2>/dev/null || echo "50")
75
+ if [[ "$overall_risk" -gt 80 ]]; then
76
+ daemon_log WARN "HIGH RISK (${overall_risk}%) predicted for issue #${issue_num} — upgrading model"
77
+ export CLAUDE_MODEL="opus"
78
+ elif [[ "$overall_risk" -lt 30 ]]; then
79
+ daemon_log INFO "LOW RISK (${overall_risk}%) predicted for issue #${issue_num}"
80
+ fi
81
+ fi
82
+ fi
83
+ fi
84
+
85
+ # Check disk space before spawning
86
+ local free_space_kb
87
+ free_space_kb=$(df -k "." 2>/dev/null | tail -1 | awk '{print $4}')
88
+ if [[ -n "$free_space_kb" ]] && [[ "$free_space_kb" -lt 1048576 ]] 2>/dev/null; then
89
+ daemon_log WARN "Low disk space ($(( free_space_kb / 1024 ))MB) — skipping issue #${issue_num}"
90
+ return 1
91
+ fi
92
+
93
+ local work_dir="" branch_name="daemon/issue-${issue_num}"
94
+
95
+ if [[ "$WATCH_MODE" == "org" && -n "$repo_full_name" ]]; then
96
+ # Org mode: use cloned repo directory
97
+ local owner="${repo_full_name%%/*}"
98
+ local repo="${repo_full_name##*/}"
99
+ work_dir=$(daemon_ensure_repo "$owner" "$repo") || return 1
100
+
101
+ # Create branch in the cloned repo
102
+ (
103
+ cd "$work_dir"
104
+ git checkout -B "$branch_name" "${BASE_BRANCH}" 2>/dev/null
105
+ ) || {
106
+ daemon_log ERROR "Failed to create branch in ${repo_full_name}"
107
+ return 1
108
+ }
109
+ daemon_log INFO "Org mode: working in ${work_dir} (${repo_full_name})"
110
+ else
111
+ # Standard mode: use git worktree
112
+ work_dir="${WORKTREE_DIR}/daemon-issue-${issue_num}"
113
+
114
+ # Serialize worktree operations with a lock file (run in subshell to auto-close FD)
115
+ mkdir -p "$WORKTREE_DIR"
116
+ local wt_ok=0
117
+ (
118
+ flock -w 30 200 2>/dev/null || true
119
+
120
+ # Clean up stale worktree if it exists
121
+ if [[ -d "$work_dir" ]]; then
122
+ git worktree remove "$work_dir" --force 2>/dev/null || true
123
+ fi
124
+ git branch -D "$branch_name" 2>/dev/null || true
125
+
126
+ git worktree add "$work_dir" -b "$branch_name" "$BASE_BRANCH" 2>/dev/null
127
+ ) 200>"${WORKTREE_DIR}/.worktree.lock"
128
+ wt_ok=$?
129
+
130
+ if [[ $wt_ok -ne 0 ]]; then
131
+ daemon_log ERROR "Failed to create worktree for issue #${issue_num}"
132
+ return 1
133
+ fi
134
+ daemon_log INFO "Worktree created at ${work_dir}"
135
+ fi
136
+
137
+ # If template is "composed", copy the composed spec into the worktree
138
+ if [[ "$PIPELINE_TEMPLATE" == "composed" ]]; then
139
+ local _src_composed="${REPO_DIR:-.}/.claude/pipeline-artifacts/composed-pipeline.json"
140
+ if [[ -f "$_src_composed" ]]; then
141
+ local _dst_artifacts="${work_dir}/.claude/pipeline-artifacts"
142
+ mkdir -p "$_dst_artifacts"
143
+ cp "$_src_composed" "$_dst_artifacts/composed-pipeline.json" 2>/dev/null || true
144
+ daemon_log INFO "Copied composed pipeline spec to worktree"
145
+ fi
146
+ fi
147
+
148
+ # Build pipeline args
149
+ local pipeline_args=("start" "--issue" "$issue_num" "--pipeline" "$PIPELINE_TEMPLATE")
150
+ if [[ "$SKIP_GATES" == "true" ]]; then
151
+ pipeline_args+=("--skip-gates")
152
+ fi
153
+ if [[ -n "$MODEL" ]]; then
154
+ pipeline_args+=("--model" "$MODEL")
155
+ fi
156
+ if [[ "$NO_GITHUB" == "true" ]]; then
157
+ pipeline_args+=("--no-github")
158
+ fi
159
+ # Pass session restart config
160
+ if [[ "${MAX_RESTARTS_CFG:-0}" -gt 0 ]]; then
161
+ pipeline_args+=("--max-restarts" "$MAX_RESTARTS_CFG")
162
+ fi
163
+ # Pass fast test command
164
+ if [[ -n "${FAST_TEST_CMD_CFG:-}" ]]; then
165
+ pipeline_args+=("--fast-test-cmd" "$FAST_TEST_CMD_CFG")
166
+ fi
167
+
168
+ # Append any extra pipeline args (from retry escalation, etc.)
169
+ if [[ ${#extra_pipeline_args[@]} -gt 0 ]]; then
170
+ pipeline_args+=("${extra_pipeline_args[@]}")
171
+ fi
172
+
173
+ # Run pipeline in work directory (background)
174
+ # Ignore SIGHUP so tmux attach/detach and process group changes don't kill the pipeline
175
+ echo -e "\n\n===== Pipeline run $(date -u +%Y-%m-%dT%H:%M:%SZ) =====" >> "$LOG_DIR/issue-${issue_num}.log" 2>/dev/null || true
176
+ (
177
+ trap '' HUP
178
+ cd "$work_dir"
179
+ exec "$SCRIPT_DIR/sw-pipeline.sh" "${pipeline_args[@]}"
180
+ ) >> "$LOG_DIR/issue-${issue_num}.log" 2>&1 200>&- &
181
+ local pid=$!
182
+
183
+ daemon_log INFO "Pipeline started for issue #${issue_num} (PID: ${pid})"
184
+
185
+ # Track the job (include repo and goal for org mode)
186
+ daemon_track_job "$issue_num" "$pid" "$work_dir" "$issue_title" "$repo_full_name" "$issue_goal"
187
+ emit_event "daemon.spawn" "issue=$issue_num" "pid=$pid" "repo=${repo_full_name:-local}"
188
+ "$SCRIPT_DIR/sw-tracker.sh" notify "spawn" "$issue_num" 2>/dev/null || true
189
+
190
+ # Comment on the issue
191
+ if [[ "$NO_GITHUB" != "true" ]]; then
192
+ local gh_args=()
193
+ if [[ -n "$repo_full_name" ]]; then
194
+ gh_args+=("--repo" "$repo_full_name")
195
+ fi
196
+ gh issue comment "$issue_num" ${gh_args[@]+"${gh_args[@]}"} --body "## 🤖 Pipeline Started
197
+
198
+ **Delivering:** ${issue_title}
199
+
200
+ | Field | Value |
201
+ |-------|-------|
202
+ | Template | \`${PIPELINE_TEMPLATE}\` |
203
+ | Branch | \`${branch_name}\` |
204
+ | Repo | \`${repo_full_name:-local}\` |
205
+ | Started | $(now_iso) |
206
+
207
+ _Progress updates will appear below as the pipeline advances through each stage._" 2>/dev/null || true
208
+ fi
209
+ }
210
+
211
+ # ─── Track Job ───────────────────────────────────────────────────────────────
212
+
213
+ daemon_track_job() {
214
+ local issue_num="$1" pid="$2" worktree="$3" title="${4:-}" repo="${5:-}" goal="${6:-}"
215
+
216
+ # Write to SQLite (non-blocking, best-effort)
217
+ if type db_save_job &>/dev/null; then
218
+ local job_id="daemon-${issue_num}-$(now_epoch)"
219
+ db_save_job "$job_id" "$issue_num" "$title" "$pid" "$worktree" "" "${PIPELINE_TEMPLATE:-autonomous}" "$goal" 2>/dev/null || true
220
+ fi
221
+
222
+ # Always write to JSON state file (primary for now)
223
+ locked_state_update \
224
+ --argjson num "$issue_num" \
225
+ --argjson pid "$pid" \
226
+ --arg wt "$worktree" \
227
+ --arg title "$title" \
228
+ --arg started "$(now_iso)" \
229
+ --arg repo "$repo" \
230
+ --arg goal "$goal" \
231
+ '.active_jobs += [{
232
+ issue: $num,
233
+ pid: $pid,
234
+ worktree: $wt,
235
+ title: $title,
236
+ started_at: $started,
237
+ repo: $repo,
238
+ goal: $goal
239
+ }]'
240
+ }
241
+
242
+ # ─── Reap Completed Jobs ────────────────────────────────────────────────────
243
+
244
+ daemon_reap_completed() {
245
+ if [[ ! -f "$STATE_FILE" ]]; then
246
+ return
247
+ fi
248
+
249
+ local jobs
250
+ jobs=$(jq -c '.active_jobs[]' "$STATE_FILE" 2>/dev/null || true)
251
+ if [[ -z "$jobs" ]]; then
252
+ return
253
+ fi
254
+
255
+ local _retry_spawned_for=""
256
+
257
+ while IFS= read -r job; do
258
+ local issue_num pid worktree
259
+ issue_num=$(echo "$job" | jq -r '.issue // empty')
260
+ pid=$(echo "$job" | jq -r '.pid // empty')
261
+ worktree=$(echo "$job" | jq -r '.worktree // empty')
262
+
263
+ # Skip malformed entries (corrupted state file)
264
+ [[ -z "$issue_num" || ! "$issue_num" =~ ^[0-9]+$ ]] && continue
265
+ [[ -z "$pid" || ! "$pid" =~ ^[0-9]+$ ]] && continue
266
+
267
+ # Check if process is still running
268
+ if kill -0 "$pid" 2>/dev/null; then
269
+ continue
270
+ fi
271
+
272
+ # Process is dead — determine exit code
273
+ # Note: wait returns 127 if process was already reaped (e.g., by init)
274
+ # In that case, check pipeline log for success/failure indicators
275
+ local exit_code=0
276
+ wait "$pid" 2>/dev/null || exit_code=$?
277
+ if [[ "$exit_code" -eq 127 ]]; then
278
+ # Process already reaped — check log file for real outcome
279
+ local issue_log="$LOG_DIR/issue-${issue_num}.log"
280
+ if [[ -f "$issue_log" ]]; then
281
+ if grep -q "Pipeline completed successfully" "$issue_log" 2>/dev/null; then
282
+ exit_code=0
283
+ elif grep -q "Pipeline failed\|ERROR.*stage.*failed\|exited with status" "$issue_log" 2>/dev/null; then
284
+ exit_code=1
285
+ else
286
+ daemon_log WARN "Could not determine exit code for issue #${issue_num} (PID ${pid} already reaped) — marking as failure"
287
+ exit_code=1
288
+ fi
289
+ else
290
+ exit_code=1
291
+ fi
292
+ fi
293
+
294
+ local started_at duration_str="" start_epoch=0 end_epoch=0
295
+ started_at=$(echo "$job" | jq -r '.started_at // empty')
296
+ if [[ -n "$started_at" ]]; then
297
+ # macOS date -j for parsing ISO dates (TZ=UTC to parse Z-suffix correctly)
298
+ start_epoch=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$started_at" +%s 2>/dev/null || date -d "$started_at" +%s 2>/dev/null || echo "0")
299
+ end_epoch=$(now_epoch)
300
+ if [[ "$start_epoch" -gt 0 ]]; then
301
+ duration_str=$(format_duration $((end_epoch - start_epoch)))
302
+ fi
303
+ fi
304
+
305
+ local result_str="success"
306
+ [[ "$exit_code" -ne 0 ]] && result_str="failure"
307
+ local dur_s=0
308
+ [[ "$start_epoch" -gt 0 ]] && dur_s=$((end_epoch - start_epoch))
309
+ emit_event "daemon.reap" "issue=$issue_num" "result=$result_str" "duration_s=$dur_s"
310
+
311
+ # Update SQLite (mark job complete/failed)
312
+ if type db_complete_job &>/dev/null && type db_fail_job &>/dev/null; then
313
+ local _db_job_id="daemon-${issue_num}-${start_epoch}"
314
+ if [[ "$exit_code" -eq 0 ]]; then
315
+ db_complete_job "$_db_job_id" "$result_str" 2>/dev/null || true
316
+ else
317
+ db_fail_job "$_db_job_id" "$result_str" 2>/dev/null || true
318
+ fi
319
+ fi
320
+
321
+ if [[ "$exit_code" -eq 0 ]]; then
322
+ daemon_on_success "$issue_num" "$duration_str"
323
+ else
324
+ daemon_on_failure "$issue_num" "$exit_code" "$duration_str"
325
+
326
+ # Cancel any lingering in_progress GitHub Check Runs for failed job
327
+ if [[ "${NO_GITHUB:-false}" != "true" && -n "$worktree" ]]; then
328
+ local check_ids_file="${worktree}/.claude/pipeline-artifacts/check-run-ids.json"
329
+ if [[ -f "$check_ids_file" ]]; then
330
+ daemon_log INFO "Cancelling in-progress check runs for issue #${issue_num}"
331
+ local _stage
332
+ while IFS= read -r _stage; do
333
+ [[ -z "$_stage" ]] && continue
334
+ # Direct API call since we're in daemon context
335
+ local _run_id
336
+ _run_id=$(jq -r --arg s "$_stage" '.[$s] // empty' "$check_ids_file" 2>/dev/null || true)
337
+ if [[ -n "$_run_id" && "$_run_id" != "null" ]]; then
338
+ local _detected
339
+ _detected=$(git remote get-url origin 2>/dev/null | sed 's|.*github.com[:/]\(.*\)\.git$|\1|' || true)
340
+ if [[ -n "$_detected" ]]; then
341
+ local _owner="${_detected%%/*}" _repo="${_detected##*/}"
342
+ gh api "repos/${_owner}/${_repo}/check-runs/${_run_id}" \
343
+ --method PATCH \
344
+ --field status=completed \
345
+ --field conclusion=cancelled \
346
+ --silent 2>/dev/null || true
347
+ fi
348
+ fi
349
+ done < <(jq -r 'keys[]' "$check_ids_file" 2>/dev/null || true)
350
+ fi
351
+ fi
352
+ fi
353
+
354
+ # Finalize memory (capture failure patterns for future runs)
355
+ if type memory_finalize_pipeline &>/dev/null 2>&1; then
356
+ local _job_state _job_artifacts
357
+ _job_state="${worktree:-.}/.claude/pipeline-state.md"
358
+ _job_artifacts="${worktree:-.}/.claude/pipeline-artifacts"
359
+ memory_finalize_pipeline "$_job_state" "$_job_artifacts" 2>/dev/null || true
360
+ fi
361
+
362
+ # Clean up progress tracking for this job
363
+ daemon_clear_progress "$issue_num"
364
+
365
+ # Release claim lock (label-based coordination)
366
+ local reap_machine_name
367
+ reap_machine_name=$(jq -r '.machines[] | select(.role == "primary") | .name' "$HOME/.shipwright/machines.json" 2>/dev/null || hostname -s)
368
+ release_claim "$issue_num" "$reap_machine_name"
369
+
370
+ # Always remove the OLD job entry from active_jobs to prevent
371
+ # re-reaping of the dead PID on the next cycle. When a retry was
372
+ # spawned, daemon_spawn_pipeline already added a fresh entry with
373
+ # the new PID — we must not leave the stale one behind.
374
+ locked_state_update --argjson num "$issue_num" \
375
+ --argjson old_pid "${pid:-0}" \
376
+ '.active_jobs = [.active_jobs[] | select(.issue != $num or .pid != $old_pid)]'
377
+ untrack_priority_job "$issue_num"
378
+
379
+ if [[ "$_retry_spawned_for" == "$issue_num" ]]; then
380
+ daemon_log INFO "Retry spawned for issue #${issue_num} — skipping worktree cleanup"
381
+ else
382
+ # Clean up worktree (skip for org-mode clones — they persist)
383
+ local job_repo
384
+ job_repo=$(echo "$job" | jq -r '.repo // ""')
385
+ if [[ -z "$job_repo" ]] && [[ -d "$worktree" ]]; then
386
+ git worktree remove "$worktree" --force 2>/dev/null || true
387
+ daemon_log INFO "Cleaned worktree: $worktree"
388
+ git branch -D "daemon/issue-${issue_num}" 2>/dev/null || true
389
+ elif [[ -n "$job_repo" ]]; then
390
+ daemon_log INFO "Org-mode: preserving clone for ${job_repo}"
391
+ fi
392
+ fi
393
+
394
+ # Dequeue next issue if available AND we have capacity
395
+ # NOTE: locked_get_active_count prevents TOCTOU race with the
396
+ # active_jobs removal above. A tiny window remains between
397
+ # the count read and dequeue_next's own lock acquisition, but
398
+ # dequeue_next is itself locked, so the worst case is a
399
+ # missed dequeue that the next poll cycle will pick up.
400
+ local current_active
401
+ current_active=$(locked_get_active_count)
402
+ if [[ "$current_active" -lt "$MAX_PARALLEL" ]]; then
403
+ local next_issue
404
+ next_issue=$(dequeue_next)
405
+ if [[ -n "$next_issue" ]]; then
406
+ local next_title
407
+ next_title=$(jq -r --arg n "$next_issue" '.titles[$n] // ""' "$STATE_FILE" 2>/dev/null || true)
408
+ daemon_log INFO "Dequeuing issue #${next_issue}: ${next_title}"
409
+ daemon_spawn_pipeline "$next_issue" "$next_title"
410
+ fi
411
+ fi
412
+ done <<< "$jobs"
413
+ }
414
+
415
+ # ─── Success Handler ────────────────────────────────────────────────────────
416
+
417
+ daemon_on_success() {
418
+ local issue_num="$1" duration="${2:-}"
419
+
420
+ # Reset consecutive failure tracking on any success
421
+ reset_failure_tracking
422
+
423
+ daemon_log SUCCESS "Pipeline completed for issue #${issue_num} (${duration:-unknown})"
424
+
425
+ # Record pipeline duration for adaptive threshold learning
426
+ if [[ -n "$duration" && "$duration" != "unknown" ]]; then
427
+ # Parse duration string back to seconds (e.g. "5m 30s" → 330)
428
+ local dur_secs=0
429
+ local _h _m _s
430
+ _h=$(echo "$duration" | grep -oE '[0-9]+h' | grep -oE '[0-9]+' || true)
431
+ _m=$(echo "$duration" | grep -oE '[0-9]+m' | grep -oE '[0-9]+' || true)
432
+ _s=$(echo "$duration" | grep -oE '[0-9]+s' | grep -oE '[0-9]+' || true)
433
+ dur_secs=$(( ${_h:-0} * 3600 + ${_m:-0} * 60 + ${_s:-0} ))
434
+ if [[ "$dur_secs" -gt 0 ]]; then
435
+ record_pipeline_duration "$PIPELINE_TEMPLATE" "$dur_secs" "success"
436
+ record_scaling_outcome "$MAX_PARALLEL" "success"
437
+ fi
438
+ fi
439
+
440
+ # Record in completed list + clear retry count for this issue
441
+ locked_state_update \
442
+ --argjson num "$issue_num" \
443
+ --arg result "success" \
444
+ --arg dur "${duration:-unknown}" \
445
+ --arg completed_at "$(now_iso)" \
446
+ '.completed += [{
447
+ issue: $num,
448
+ result: $result,
449
+ duration: $dur,
450
+ completed_at: $completed_at
451
+ }] | .completed = .completed[-500:]
452
+ | del(.retry_counts[($num | tostring)])'
453
+
454
+ if [[ "$NO_GITHUB" != "true" ]]; then
455
+ # Remove watch label, add success label
456
+ gh issue edit "$issue_num" \
457
+ --remove-label "$ON_SUCCESS_REMOVE_LABEL" \
458
+ --add-label "$ON_SUCCESS_ADD_LABEL" 2>/dev/null || true
459
+
460
+ # Comment on issue
461
+ gh issue comment "$issue_num" --body "## ✅ Pipeline Complete
462
+
463
+ The autonomous pipeline finished successfully.
464
+
465
+ | Field | Value |
466
+ |-------|-------|
467
+ | Duration | ${duration:-unknown} |
468
+ | Completed | $(now_iso) |
469
+
470
+ Check the associated PR for the implementation." 2>/dev/null || true
471
+
472
+ # Optionally close the issue
473
+ if [[ "$ON_SUCCESS_CLOSE_ISSUE" == "true" ]]; then
474
+ gh issue close "$issue_num" 2>/dev/null || true
475
+ fi
476
+ fi
477
+
478
+ notify "Pipeline Complete — Issue #${issue_num}" \
479
+ "Duration: ${duration:-unknown}" "success"
480
+ "$SCRIPT_DIR/sw-tracker.sh" notify "completed" "$issue_num" 2>/dev/null || true
481
+
482
+ # PM agent: record success for learning
483
+ if [[ -x "$SCRIPT_DIR/sw-pm.sh" ]]; then
484
+ bash "$SCRIPT_DIR/sw-pm.sh" learn "$issue_num" success 2>/dev/null || true
485
+ fi
486
+ }
487
+
488
+ # ─── Failure Classification ─────────────────────────────────────────────────
489
+