diff --git a/.agents/scripts/supervisor-helper.sh b/.agents/scripts/supervisor-helper.sh index c55c58b4b4..748160a401 100755 --- a/.agents/scripts/supervisor-helper.sh +++ b/.agents/scripts/supervisor-helper.sh @@ -2379,160 +2379,6 @@ cmd_transition() { return 0 } -####################################### -# Trigger a release via version-manager.sh when a batch completes (t128.10) -# -# Called from check_batch_completion() when a batch with release_on_complete=1 -# reaches completion. Runs version-manager.sh release from the batch's repo -# on the main branch. -# -# $1: batch_id -# $2: release_type (major|minor|patch) -# $3: repo path (from first task in batch) -####################################### -trigger_batch_release() { - local batch_id="$1" - local release_type="$2" - local repo="$3" - - local version_manager="${SCRIPT_DIR}/version-manager.sh" - if [[ ! -x "$version_manager" ]]; then - log_error "version-manager.sh not found or not executable: $version_manager" - return 1 - fi - - if [[ -z "$repo" || ! -d "$repo" ]]; then - log_error "Invalid repo path for batch release: $repo" - return 1 - fi - - # Validate release_type - case "$release_type" in - major | minor | patch) ;; - *) - log_error "Invalid release type for batch $batch_id: $release_type" - return 1 - ;; - esac - - local escaped_batch - escaped_batch=$(sql_escape "$batch_id") - local batch_name - batch_name=$(db "$SUPERVISOR_DB" "SELECT name FROM batches WHERE id = '$escaped_batch';" 2>/dev/null || echo "$batch_id") - - # Gather batch stats for the release log - local total_tasks complete_count failed_count - total_tasks=$(db "$SUPERVISOR_DB" " - SELECT count(*) FROM batch_tasks WHERE batch_id = '$escaped_batch'; - ") - complete_count=$(db "$SUPERVISOR_DB" " - SELECT count(*) FROM batch_tasks bt JOIN tasks t ON bt.task_id = t.id - WHERE bt.batch_id = '$escaped_batch' AND t.status IN ('complete', 'deployed', 'merged'); - ") - failed_count=$(db "$SUPERVISOR_DB" " - SELECT count(*) FROM batch_tasks bt JOIN tasks t ON bt.task_id = t.id - WHERE bt.batch_id = '$escaped_batch' AND t.status IN ('failed', 'blocked'); - ") - - log_info "Triggering $release_type release for batch $batch_name ($complete_count/$total_tasks tasks complete, $failed_count failed)" - - # Release must run from the main repo on the main branch - # version-manager.sh handles: bump, update files, changelog, tag, push, GitHub release - local release_log - release_log="$SUPERVISOR_DIR/logs/release-${batch_id}-$(date +%Y%m%d%H%M%S).log" - mkdir -p "$SUPERVISOR_DIR/logs" - - # Ensure we're on main and in sync before releasing - local current_branch - current_branch=$(git -C "$repo" branch --show-current 2>/dev/null || echo "") - if [[ "$current_branch" != "main" ]]; then - log_warn "Repo not on main branch (on: $current_branch), switching..." - git -C "$repo" checkout main 2>/dev/null || { - log_error "Failed to switch to main branch for release" - return 1 - } - fi - - # t276: Stash any dirty working tree before release. - # Common cause: todo/VERIFY.md, untracked files from parallel sessions. - # version-manager.sh refuses to release with uncommitted changes. - local stashed=false - if [[ -n "$(git -C "$repo" status --porcelain 2>/dev/null)" ]]; then - log_info "Stashing dirty working tree before release..." - if git -C "$repo" stash push -m "auto-release-stash-$(date +%Y%m%d%H%M%S)" 2>/dev/null; then - stashed=true - else - log_warn "git stash failed, proceeding anyway (release may fail)" - fi - fi - - # Pull latest (all batch PRs should be merged by now) - git -C "$repo" pull --ff-only origin main 2>/dev/null || { - log_warn "Fast-forward pull failed, trying rebase..." - git -C "$repo" pull --rebase origin main 2>/dev/null || { - log_error "Failed to pull latest main for release" - [[ "$stashed" == "true" ]] && git -C "$repo" stash pop 2>/dev/null || true - return 1 - } - } - - # Run the release (--skip-preflight: batch tasks already passed CI individually) - # Use --force to bypass empty CHANGELOG check (auto-generates from commits) - local release_output="" - local release_exit=0 - release_output=$(cd "$repo" && bash "$version_manager" release "$release_type" --skip-preflight --force 2>&1) || release_exit=$? - - # t276: Restore stashed changes after release (regardless of success/failure) - if [[ "$stashed" == "true" ]]; then - log_info "Restoring stashed working tree..." - git -C "$repo" stash pop 2>/dev/null || log_warn "git stash pop failed (may need manual recovery)" - fi - - echo "$release_output" >"$release_log" 2>/dev/null || true - - if [[ "$release_exit" -ne 0 ]]; then - log_error "Release failed for batch $batch_name (exit: $release_exit)" - log_error "See log: $release_log" - # Store failure in memory for future reference - if [[ -x "$MEMORY_HELPER" ]]; then - "$MEMORY_HELPER" store \ - --auto \ - --type "FAILED_APPROACH" \ - --content "Batch release failed: $batch_name ($release_type). Exit: $release_exit. Check $release_log" \ - --tags "supervisor,release,batch,$batch_name,failed" \ - 2>/dev/null || true - fi - # Send notification about release failure - send_task_notification "batch-$batch_id" "failed" "Batch release ($release_type) failed for $batch_name" 2>/dev/null || true - return 1 - fi - - # Extract the new version from the release output - local new_version - new_version=$(echo "$release_output" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | tail -1 || echo "unknown") - - log_success "Release $new_version created for batch $batch_name ($release_type)" - - # Store success in memory - if [[ -x "$MEMORY_HELPER" ]]; then - "$MEMORY_HELPER" store \ - --auto \ - --type "WORKING_SOLUTION" \ - --content "Batch release succeeded: $batch_name -> v$new_version ($release_type). $complete_count/$total_tasks tasks, $failed_count failed." \ - --tags "supervisor,release,batch,$batch_name,success,v$new_version" \ - 2>/dev/null || true - fi - - # Send notification about successful release - send_task_notification "batch-$batch_id" "deployed" "Released v$new_version ($release_type) for batch $batch_name" 2>/dev/null || true - - # macOS celebration notification - if [[ "$(uname)" == "Darwin" ]]; then - nohup afplay /System/Library/Sounds/Hero.aiff &>/dev/null & - fi - - return 0 -} ####################################### # Check if a batch is complete after task state change @@ -11267,67 +11113,6 @@ create_github_issue() { # issue-sync-helper.sh push (called from create_github_issue) and committed # by commit_and_push_todo within create_github_issue itself. -####################################### -# Commit and push TODO.md with pull-rebase retry -# Handles concurrent push conflicts from parallel workers -# Args: $1=repo_path $2=commit_message $3=max_retries (default 3) -####################################### -commit_and_push_todo() { - local repo_path="$1" - local commit_msg="$2" - local max_retries="${3:-3}" - - if git -C "$repo_path" diff --quiet -- TODO.md 2>>"$SUPERVISOR_LOG"; then - log_info "No changes to commit (TODO.md unchanged)" - return 0 - fi - - git -C "$repo_path" add TODO.md - - local attempt=0 - while [[ "$attempt" -lt "$max_retries" ]]; do - attempt=$((attempt + 1)) - - # Pull-rebase to incorporate any concurrent TODO.md pushes - if ! git -C "$repo_path" pull --rebase --autostash 2>>"$SUPERVISOR_LOG"; then - log_warn "Pull-rebase failed (attempt $attempt/$max_retries)" - # Abort rebase if in progress and retry - git -C "$repo_path" rebase --abort 2>>"$SUPERVISOR_LOG" || true - sleep "$attempt" - continue - fi - - # Re-stage TODO.md (rebase may have resolved it) - if ! git -C "$repo_path" diff --quiet -- TODO.md 2>>"$SUPERVISOR_LOG"; then - git -C "$repo_path" add TODO.md - fi - - # Check if our change survived the rebase (may have been applied by another worker) - if git -C "$repo_path" diff --cached --quiet -- TODO.md 2>>"$SUPERVISOR_LOG"; then - log_info "TODO.md change already applied (likely by another worker)" - return 0 - fi - - # Commit - if ! git -C "$repo_path" commit -m "$commit_msg" -- TODO.md 2>>"$SUPERVISOR_LOG"; then - log_warn "Commit failed (attempt $attempt/$max_retries)" - sleep "$attempt" - continue - fi - - # Push - if git -C "$repo_path" push 2>>"$SUPERVISOR_LOG"; then - log_success "Committed and pushed TODO.md update" - return 0 - fi - - log_warn "Push failed (attempt $attempt/$max_retries) - will pull-rebase and retry" - sleep "$attempt" - done - - log_error "Failed to push TODO.md after $max_retries attempts" - return 1 -} ####################################### # Verify task has real deliverables before marking complete (t163.4) @@ -11438,151 +11223,6 @@ verify_task_deliverables() { # based on file types (shellcheck for .sh, file-exists for new files, etc.) # Appends a new entry to the VERIFY-QUEUE in todo/VERIFY.md ####################################### -populate_verify_queue() { - local task_id="$1" - local pr_url="${2:-}" - local repo="${3:-}" - - if [[ -z "$repo" ]]; then - log_warn "populate_verify_queue: no repo for $task_id" - return 1 - fi - - local verify_file="$repo/todo/VERIFY.md" - if [[ ! -f "$verify_file" ]]; then - log_info "No VERIFY.md at $verify_file — skipping verify queue population" - return 0 - fi - - # Extract PR number and repo slug (t232) - local parsed_populate pr_number repo_slug - parsed_populate=$(parse_pr_url "$pr_url") || parsed_populate="" - if [[ -z "$parsed_populate" ]]; then - log_warn "populate_verify_queue: cannot parse PR URL for $task_id: $pr_url" - return 1 - fi - repo_slug="${parsed_populate%%|*}" - pr_number="${parsed_populate##*|}" - - # Check if this task already has a verify entry (idempotency) - if grep -q "^- \[.\] v[0-9]* $task_id " "$verify_file" 2>/dev/null; then - log_info "Verify entry already exists for $task_id in VERIFY.md" - return 0 - fi - - # Get changed files from PR - local changed_files - if ! changed_files=$(gh pr view "$pr_number" --repo "$repo_slug" --json files --jq '.files[].path' 2>>"$SUPERVISOR_LOG"); then - log_warn "populate_verify_queue: failed to fetch PR files for $task_id (#$pr_number)" - return 1 - fi - - if [[ -z "$changed_files" ]]; then - log_info "No files changed in PR #$pr_number for $task_id" - return 0 - fi - - # Filter to substantive files (skip TODO.md, planning files) - local substantive_files - substantive_files=$(echo "$changed_files" | grep -vE '^(TODO\.md$|todo/)' || true) - - if [[ -z "$substantive_files" ]]; then - log_info "No substantive files in PR #$pr_number for $task_id — skipping verify" - return 0 - fi - - # Get task description from DB - local task_desc - task_desc=$(db "$SUPERVISOR_DB" "SELECT description FROM tasks WHERE id = '$(sql_escape "$task_id")';" 2>/dev/null || echo "$task_id") - # Truncate long descriptions - if [[ ${#task_desc} -gt 60 ]]; then - task_desc="${task_desc:0:57}..." - fi - - # Determine next verify ID - local last_vnum - last_vnum=$(grep -oE 'v[0-9]+' "$verify_file" | grep -oE '[0-9]+' | sort -n | tail -1 || echo "0") - last_vnum=$((10#$last_vnum)) - local next_vnum=$((last_vnum + 1)) - local verify_id - verify_id=$(printf "v%03d" "$next_vnum") - - local today - today=$(date +%Y-%m-%d) - - # Build the verify entry - local entry="" - entry+="- [ ] $verify_id $task_id $task_desc | PR #$pr_number | merged:$today" - entry+=$'\n' - entry+=" files: $(echo "$substantive_files" | tr '\n' ',' | sed 's/,$//' | sed 's/,/, /g')" - - # Generate check directives based on file types - local checks="" - while IFS= read -r file; do - [[ -z "$file" ]] && continue - case "$file" in - *.sh) - checks+=$'\n'" check: shellcheck $file" - checks+=$'\n'" check: file-exists $file" - ;; - *.md) - checks+=$'\n'" check: file-exists $file" - ;; - *.toon) - checks+=$'\n'" check: file-exists $file" - ;; - *.yml | *.yaml) - checks+=$'\n'" check: file-exists $file" - ;; - *.json) - checks+=$'\n'" check: file-exists $file" - ;; - *) - checks+=$'\n'" check: file-exists $file" - ;; - esac - done <<<"$substantive_files" - - # Also add subagent-index check if any .md files in .agents/ were changed - if echo "$substantive_files" | grep -qE '\.agents/.*\.md$'; then - local base_names - base_names=$(echo "$substantive_files" | grep -E '\.agents/.*\.md$' | xargs -I{} basename {} .md || true) - while IFS= read -r bname; do - [[ -z "$bname" ]] && continue - # Only check for subagent-index entries for tool/service/workflow files - if echo "$substantive_files" | grep -qE "\.agents/(tools|services|workflows)/.*${bname}\.md$"; then - checks+=$'\n'" check: rg \"$bname\" .agents/subagent-index.toon" - fi - done <<<"$base_names" - fi - - entry+="$checks" - - # Append to VERIFY.md before the end marker - if grep -q '' "$verify_file"; then - # Insert before the end marker - local temp_file - temp_file=$(mktemp) - _save_cleanup_scope - trap '_run_cleanups' RETURN - push_cleanup "rm -f '${temp_file}'" - awk -v entry="$entry" ' - // { - print entry - print "" - } - { print } - ' "$verify_file" >"$temp_file" - mv "$temp_file" "$verify_file" - else - # No end marker — append to end of file - echo "" >>"$verify_file" - echo "$entry" >>"$verify_file" - fi - - log_success "Added verify entry $verify_id for $task_id to VERIFY.md" - return 0 -} ####################################### # Run verification checks for a task from VERIFY.md (t180.3) @@ -11749,94 +11389,7 @@ run_verify_checks() { ####################################### # Mark a verify entry as passed [x] or failed [!] in VERIFY.md (t180.3) ####################################### -mark_verify_entry() { - local verify_file="$1" - local task_id="$2" - local result="$3" - local today="${4:-$(date +%Y-%m-%d)}" - local reason="${5:-}" - - if [[ "$result" == "pass" ]]; then - # Mark [x] and add verified:date - sed -i.bak "s/^- \[ \] \(v[0-9]* $task_id .*\)/- [x] \1 verified:$today/" "$verify_file" - else - # Mark [!] and add failed:date reason:description - local escaped_reason - escaped_reason=$(echo "$reason" | sed 's/[&/\]/\\&/g' | head -c 200) - sed -i.bak "s/^- \[ \] \(v[0-9]* $task_id .*\)/- [!] \1 failed:$today reason:$escaped_reason/" "$verify_file" - fi - rm -f "${verify_file}.bak" - - return 0 -} - -####################################### -# Process verification queue — run checks for deployed tasks (t180.3) -# Scans VERIFY.md for pending entries, runs checks, updates states -# Called from pulse Phase 6 -####################################### -process_verify_queue() { - local batch_id="${1:-}" - - ensure_db - - # Find deployed tasks that need verification - local deployed_tasks - local where_clause="t.status = 'deployed'" - if [[ -n "$batch_id" ]]; then - where_clause="$where_clause AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.task_id = t.id AND bt.batch_id = '$(sql_escape "$batch_id")')" - fi - - deployed_tasks=$(db -separator '|' "$SUPERVISOR_DB" " - SELECT t.id, t.repo, t.pr_url FROM tasks t - WHERE $where_clause - ORDER BY t.updated_at ASC; - ") - - if [[ -z "$deployed_tasks" ]]; then - return 0 - fi - - local verified_count=0 - local failed_count=0 - - while IFS='|' read -r tid trepo tpr; do - [[ -z "$tid" ]] && continue - - local verify_file="$trepo/todo/VERIFY.md" - if [[ ! -f "$verify_file" ]]; then - continue - fi - - # Check if there's a pending verify entry for this task - if ! grep -q "^- \[ \] v[0-9]* $tid " "$verify_file" 2>/dev/null; then - continue - fi - - log_info " $tid: running verification checks" - cmd_transition "$tid" "verifying" 2>>"$SUPERVISOR_LOG" || { - log_warn " $tid: failed to transition to verifying" - continue - } - - if run_verify_checks "$tid" "$trepo"; then - cmd_transition "$tid" "verified" 2>>"$SUPERVISOR_LOG" || true - verified_count=$((verified_count + 1)) - log_success " $tid: VERIFIED" - else - cmd_transition "$tid" "verify_failed" 2>>"$SUPERVISOR_LOG" || true - failed_count=$((failed_count + 1)) - log_warn " $tid: VERIFY FAILED" - send_task_notification "$tid" "verify_failed" "Post-merge verification failed" 2>>"$SUPERVISOR_LOG" || true - fi - done <<<"$deployed_tasks" - - if [[ $((verified_count + failed_count)) -gt 0 ]]; then - log_info "Verification: $verified_count passed, $failed_count failed" - fi - return 0 -} ####################################### # Command: verify — manually run verification for a task (t180.3) @@ -11902,406 +11455,53 @@ cmd_verify() { ####################################### # Commit and push VERIFY.md changes after verification (t180.3) ####################################### -commit_verify_changes() { - local repo="$1" - local task_id="$2" - local result="$3" - local verify_file="$repo/todo/VERIFY.md" - if [[ ! -f "$verify_file" ]]; then - return 0 - fi - # Check if there are changes to commit - if ! git -C "$repo" diff --quiet -- "todo/VERIFY.md" 2>/dev/null; then - local msg="chore: mark $task_id verification $result in VERIFY.md [skip ci]" - git -C "$repo" add "todo/VERIFY.md" 2>>"$SUPERVISOR_LOG" || return 1 - git -C "$repo" commit -m "$msg" 2>>"$SUPERVISOR_LOG" || return 1 - git -C "$repo" push origin main 2>>"$SUPERVISOR_LOG" || return 1 - log_info "Committed VERIFY.md update for $task_id ($result)" - fi - return 0 -} ####################################### -# Update TODO.md when a task completes -# Marks the task checkbox as [x], adds completed:YYYY-MM-DD -# Then commits and pushes the change -# Guard (t163): requires verified deliverables before marking [x] +# Post a comment to GitHub issue when a worker is blocked (t296) +# Extracts the GitHub issue number from TODO.md ref:GH# field +# Posts a comment explaining what's needed and removes auto-dispatch label +# Args: task_id, blocked_reason, repo_path ####################################### -update_todo_on_complete() { +post_blocked_comment_to_github() { local task_id="$1" + local reason="${2:-unknown}" + local repo_path="$3" - ensure_db - - local escaped_id - escaped_id=$(sql_escape "$task_id") - local task_row - task_row=$(db -separator '|' "$SUPERVISOR_DB" " - SELECT repo, description, pr_url FROM tasks WHERE id = '$escaped_id'; - ") - - if [[ -z "$task_row" ]]; then - log_error "Task not found: $task_id" - return 1 - fi - - local trepo tdesc tpr_url - IFS='|' read -r trepo tdesc tpr_url <<<"$task_row" - - # Verify deliverables before marking complete (t163.4) - if ! verify_task_deliverables "$task_id" "$tpr_url" "$trepo"; then - log_warn "Task $task_id failed deliverable verification - NOT marking [x] in TODO.md" - log_warn " To manually verify: add 'verified:$(date +%Y-%m-%d)' to the task line" - return 1 + # Check if gh CLI is available + if ! command -v gh &>/dev/null; then + log_warn "gh CLI not available, skipping GitHub issue comment for $task_id" + return 0 fi - local todo_file="$trepo/TODO.md" + # Extract GitHub issue number from TODO.md + local todo_file="$repo_path/TODO.md" if [[ ! -f "$todo_file" ]]; then - log_warn "TODO.md not found at $todo_file" - return 1 + return 0 fi - # t278: Guard against marking #plan tasks complete when subtasks are still open. - # A #plan task is a parent that was decomposed into subtasks. It should only be - # marked [x] when ALL its subtasks are [x]. This prevents decomposition workers - # from prematurely completing the parent. local task_line - task_line=$(grep -E "^[[:space:]]*- \[[ x-]\] ${task_id}( |$)" "$todo_file" | head -1 || true) - if [[ -n "$task_line" && "$task_line" == *"#plan"* ]]; then - # Get the indentation level of this task - local task_indent - task_indent=$(echo "$task_line" | sed -E 's/^([[:space:]]*).*/\1/' | wc -c) - task_indent=$((task_indent - 1)) # wc -c counts newline - - # Check for open subtasks (lines indented deeper with [ ]) - local open_subtasks - open_subtasks=$(awk -v tid="$task_id" -v tindent="$task_indent" ' - BEGIN { found=0 } - /- \[[ x-]\] '"$task_id"'( |$)/ { found=1; next } - found && /^[[:space:]]*- \[/ { - # Count leading spaces - match($0, /^[[:space:]]*/); - line_indent = RLENGTH; - if (line_indent > tindent) { - if ($0 ~ /- \[ \]/) { print $0 } - } else { found=0 } - } - found && /^[[:space:]]*$/ { next } - found && !/^[[:space:]]*- / && !/^[[:space:]]*$/ { found=0 } - ' "$todo_file") - - if [[ -n "$open_subtasks" ]]; then - local open_count - open_count=$(echo "$open_subtasks" | wc -l | tr -d ' ') - log_warn "Task $task_id is a #plan task with $open_count open subtask(s) — NOT marking [x]" - log_warn " Parent #plan tasks should only be completed when all subtasks are done" - return 1 - fi + task_line=$(grep -E "^[[:space:]]*- \[.\] ${task_id} " "$todo_file" | head -1 || echo "") + if [[ -z "$task_line" ]]; then + return 0 fi - local today - today=$(date +%Y-%m-%d) - - # Match the task line (open checkbox with task ID) - # Handles both top-level and indented subtasks - if ! grep -qE "^[[:space:]]*- \[ \] ${task_id}( |$)" "$todo_file"; then - log_warn "Task $task_id not found as open in $todo_file (may already be completed)" + local gh_issue_num + gh_issue_num=$(echo "$task_line" | grep -oE 'ref:GH#[0-9]+' | head -1 | sed 's/ref:GH#//' || echo "") + if [[ -z "$gh_issue_num" ]]; then + log_info "No GitHub issue reference found for $task_id, skipping comment" return 0 fi - # Mark as complete: [ ] -> [x], append completed:date - # Use sed to match the line and transform it - local sed_pattern="s/^([[:space:]]*- )\[ \] (${task_id} .*)$/\1[x] \2 completed:${today}/" - - sed_inplace -E "$sed_pattern" "$todo_file" - - # Verify the change was made - if ! grep -qE "^[[:space:]]*- \[x\] ${task_id} " "$todo_file"; then - log_error "Failed to update TODO.md for $task_id" - return 1 - fi - - log_success "Updated TODO.md: $task_id marked complete ($today)" - - local commit_msg="chore: mark $task_id complete in TODO.md" - if [[ -n "$tpr_url" ]]; then - commit_msg="chore: mark $task_id complete in TODO.md (${tpr_url})" - fi - commit_and_push_todo "$trepo" "$commit_msg" - return $? -} - -####################################### -# Generate a VERIFY.md entry for a deployed task (t180.4) -# Auto-creates check directives based on PR files: -# - .sh files: shellcheck + bash -n + file-exists -# - .md files: file-exists -# - test files: bash -# - other: file-exists -# Appends entry before marker -# $1: task_id -####################################### -generate_verify_entry() { - local task_id="$1" - - ensure_db - - local escaped_id - escaped_id=$(sql_escape "$task_id") - local task_row - task_row=$(db -separator '|' "$SUPERVISOR_DB" " - SELECT repo, description, pr_url FROM tasks WHERE id = '$escaped_id'; - ") - - if [[ -z "$task_row" ]]; then - log_warn "generate_verify_entry: task not found: $task_id" - return 1 - fi - - local trepo tdesc tpr_url - IFS='|' read -r trepo tdesc tpr_url <<<"$task_row" - - local verify_file="$trepo/todo/VERIFY.md" - if [[ ! -f "$verify_file" ]]; then - log_warn "generate_verify_entry: VERIFY.md not found at $verify_file" - return 1 - fi - - # Check if entry already exists for this task - local task_id_escaped - task_id_escaped=$(printf '%s' "$task_id" | sed 's/\./\\./g') - if grep -qE "^- \[.\] v[0-9]+ ${task_id_escaped} " "$verify_file"; then - log_info "generate_verify_entry: entry already exists for $task_id" - return 0 - fi - - # Get next vNNN number - local last_v - last_v=$(grep -oE '^- \[.\] v([0-9]+)' "$verify_file" | grep -oE '[0-9]+' | sort -n | tail -1 || echo "0") - last_v=$((10#$last_v)) - local next_v=$((last_v + 1)) - local vid - vid=$(printf "v%03d" "$next_v") - - # Extract PR number - local pr_number="" - if [[ "$tpr_url" =~ /pull/([0-9]+) ]]; then - pr_number="${BASH_REMATCH[1]}" - fi - - local today - today=$(date +%Y-%m-%d) - - # Get files changed in PR (requires gh CLI) - local files_list="" - local -a check_lines=() - - if [[ -n "$pr_number" ]] && command -v gh &>/dev/null && check_gh_auth; then - local repo_slug="" - repo_slug=$(detect_repo_slug "$trepo" 2>/dev/null || echo "") - if [[ -n "$repo_slug" ]]; then - files_list=$(gh pr view "$pr_number" --repo "$repo_slug" --json files --jq '.files[].path' 2>/dev/null | tr '\n' ', ' | sed 's/,$//') - - # Generate check directives based on file types - while IFS= read -r fpath; do - [[ -z "$fpath" ]] && continue - case "$fpath" in - tests/*.sh | test-*.sh) - check_lines+=(" check: bash $fpath") - ;; - *.sh) - check_lines+=(" check: file-exists $fpath") - check_lines+=(" check: shellcheck $fpath") - check_lines+=(" check: bash -n $fpath") - ;; - *.md) - check_lines+=(" check: file-exists $fpath") - ;; - *) - check_lines+=(" check: file-exists $fpath") - ;; - esac - done < <(gh pr view "$pr_number" --repo "$repo_slug" --json files --jq '.files[].path' 2>/dev/null) - fi - fi - - # Fallback: if no checks generated, add basic file-exists for PR - if [[ ${#check_lines[@]} -eq 0 && -n "$pr_number" ]]; then - check_lines+=(" check: rg \"$task_id\" $trepo/TODO.md") - fi - - # Build the entry - local entry_header="- [ ] $vid $task_id ${tdesc%% *} | PR #${pr_number:-unknown} | merged:$today" - local entry_body="" - if [[ -n "$files_list" ]]; then - entry_body+=" files: $files_list"$'\n' - fi - for cl in "${check_lines[@]}"; do - entry_body+="$cl"$'\n' - done - - # Insert before - local marker="" - if ! grep -q "$marker" "$verify_file"; then - log_warn "generate_verify_entry: VERIFY-QUEUE-END marker not found" - return 1 - fi - - # Build full entry text - local full_entry - full_entry=$(printf '%s\n%s\n' "$entry_header" "$entry_body") - - # Insert before marker using temp file (portable across macOS/Linux) - local tmp_file - tmp_file=$(mktemp) - _save_cleanup_scope - trap '_run_cleanups' RETURN - push_cleanup "rm -f '${tmp_file}'" - awk -v entry="$full_entry" -v mark="$marker" '{ - if (index($0, mark) > 0) { print entry; } - print; - }' "$verify_file" >"$tmp_file" && mv "$tmp_file" "$verify_file" - - log_success "Generated verify entry $vid for $task_id (PR #${pr_number:-unknown})" - - # Commit and push - commit_and_push_todo "$trepo" "chore: add verify entry $vid for $task_id" 2>>"$SUPERVISOR_LOG" || true - - return 0 -} - -####################################### -# Process pending verifications from VERIFY.md (t180.4) -# Called as Phase 3b of the pulse cycle. -# Runs verify-run-helper.sh on pending entries, then transitions -# tasks from deployed -> verified or verify_failed. -# $1: batch_id (optional, for filtering) -####################################### -process_verify_queue() { - local batch_id="${1:-}" - - ensure_db - - # Find deployed tasks that need verification - local where_clause="t.status = 'deployed'" - if [[ -n "$batch_id" ]]; then - where_clause="$where_clause AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.task_id = t.id AND bt.batch_id = '$(sql_escape "$batch_id")')" - fi - - local deployed_tasks - deployed_tasks=$(db -separator '|' "$SUPERVISOR_DB" " - SELECT t.id, t.repo FROM tasks t - WHERE $where_clause - ORDER BY t.completed_at ASC - LIMIT 5; - ") - - if [[ -z "$deployed_tasks" ]]; then - return 0 - fi - - local verify_script="${SCRIPT_DIR}/verify-run-helper.sh" - if [[ ! -x "$verify_script" ]]; then - log_verbose " Phase 3b: verify-run-helper.sh not found" - return 0 - fi - - local verified_count=0 - local failed_count=0 - - while IFS='|' read -r tid trepo; do - local verify_file="$trepo/todo/VERIFY.md" - [[ -f "$verify_file" ]] || continue - - # Find the verify entry for this task - local task_id_escaped - task_id_escaped=$(printf '%s' "$tid" | sed 's/\./\\./g') - local vid="" - vid=$(grep -oE "^- \[ \] (v[0-9]+) ${task_id_escaped} " "$verify_file" | grep -oE 'v[0-9]+' | head -1 || echo "") - - if [[ -z "$vid" ]]; then - # No pending verify entry -- generate one - generate_verify_entry "$tid" 2>>"$SUPERVISOR_LOG" || continue - vid=$(grep -oE "^- \[ \] (v[0-9]+) ${task_id_escaped} " "$verify_file" | grep -oE 'v[0-9]+' | head -1 || echo "") - [[ -z "$vid" ]] && continue - fi - - # Run verification - log_info " Phase 3b: Verifying $tid ($vid)" - if (cd "$trepo" && bash "$verify_script" run "$vid" 2>>"$SUPERVISOR_LOG"); then - # Check if it passed (entry marked [x]) - if grep -qE "^- \[x\] $vid " "$verify_file"; then - cmd_transition "$tid" "verified" 2>>"$SUPERVISOR_LOG" || true - verified_count=$((verified_count + 1)) - log_success " $tid: verified ($vid passed)" - elif grep -qE "^- \[!\] $vid " "$verify_file"; then - cmd_transition "$tid" "verify_failed" 2>>"$SUPERVISOR_LOG" || true - failed_count=$((failed_count + 1)) - log_warn " $tid: verification failed ($vid)" - fi - else - log_warn " $tid: verify-run-helper.sh failed for $vid" - fi - done <<<"$deployed_tasks" - - if [[ $((verified_count + failed_count)) -gt 0 ]]; then - log_info " Phase 3b: Verified $verified_count, failed $failed_count" - # Commit verify results - local first_repo - first_repo=$(echo "$deployed_tasks" | head -1 | cut -d'|' -f2) - if [[ -n "$first_repo" ]]; then - commit_and_push_todo "$first_repo" "chore: verify results (${verified_count} passed, ${failed_count} failed)" 2>>"$SUPERVISOR_LOG" || true - fi - fi - - return 0 -} - -####################################### -# Post a comment to GitHub issue when a worker is blocked (t296) -# Extracts the GitHub issue number from TODO.md ref:GH# field -# Posts a comment explaining what's needed and removes auto-dispatch label -# Args: task_id, blocked_reason, repo_path -####################################### -post_blocked_comment_to_github() { - local task_id="$1" - local reason="${2:-unknown}" - local repo_path="$3" - - # Check if gh CLI is available - if ! command -v gh &>/dev/null; then - log_warn "gh CLI not available, skipping GitHub issue comment for $task_id" - return 0 - fi - - # Extract GitHub issue number from TODO.md - local todo_file="$repo_path/TODO.md" - if [[ ! -f "$todo_file" ]]; then - return 0 - fi - - local task_line - task_line=$(grep -E "^[[:space:]]*- \[.\] ${task_id} " "$todo_file" | head -1 || echo "") - if [[ -z "$task_line" ]]; then - return 0 - fi - - local gh_issue_num - gh_issue_num=$(echo "$task_line" | grep -oE 'ref:GH#[0-9]+' | head -1 | sed 's/ref:GH#//' || echo "") - if [[ -z "$gh_issue_num" ]]; then - log_info "No GitHub issue reference found for $task_id, skipping comment" - return 0 - fi - - # Detect repo slug - local repo_slug - repo_slug=$(detect_repo_slug "$repo_path" 2>/dev/null || echo "") - if [[ -z "$repo_slug" ]]; then - log_warn "Could not detect repo slug for $repo_path, skipping GitHub comment" - return 0 - fi + # Detect repo slug + local repo_slug + repo_slug=$(detect_repo_slug "$repo_path" 2>/dev/null || echo "") + if [[ -z "$repo_slug" ]]; then + log_warn "Could not detect repo slug for $repo_path, skipping GitHub comment" + return 0 + fi # Construct the comment body local comment_body @@ -12336,76 +11536,6 @@ The supervisor will automatically retry this task once it's tagged with \`#auto- return 0 } -####################################### -# Update TODO.md when a task is blocked or failed -# Adds Notes line with blocked reason -# Then commits and pushes the change -# t296: Also posts a comment to GitHub issue if ref:GH# exists -####################################### -update_todo_on_blocked() { - local task_id="$1" - local reason="${2:-unknown}" - - ensure_db - - local escaped_id - escaped_id=$(sql_escape "$task_id") - local trepo - trepo=$(db "$SUPERVISOR_DB" "SELECT repo FROM tasks WHERE id = '$escaped_id';") - - if [[ -z "$trepo" ]]; then - log_error "Task not found: $task_id" - return 1 - fi - - local todo_file="$trepo/TODO.md" - if [[ ! -f "$todo_file" ]]; then - log_warn "TODO.md not found at $todo_file" - return 1 - fi - - # Find the task line number - local line_num - line_num=$(grep -nE "^[[:space:]]*- \[ \] ${task_id}( |$)" "$todo_file" | head -1 | cut -d: -f1) - - if [[ -z "$line_num" ]]; then - log_warn "Task $task_id not found as open in $todo_file" - return 0 - fi - - # Detect indentation of the task line for proper Notes alignment - local task_line - task_line=$(sed -n "${line_num}p" "$todo_file") - local indent="" - indent=$(echo "$task_line" | sed -E 's/^([[:space:]]*).*/\1/') - - # Check if a Notes line already exists below the task - local next_line_num=$((line_num + 1)) - local next_line - next_line=$(sed -n "${next_line_num}p" "$todo_file" 2>/dev/null || echo "") - - # Sanitize reason for safe insertion (escape special sed chars) - local safe_reason - safe_reason=$(echo "$reason" | sed 's/[&/\]/\\&/g' | head -c 200) - - if echo "$next_line" | grep -qE "^[[:space:]]*- Notes:"; then - # Append to existing Notes line - local append_text=" BLOCKED: ${safe_reason}" - sed_inplace "${next_line_num}s/$/${append_text}/" "$todo_file" - else - # Insert a new Notes line after the task - local notes_line="${indent} - Notes: BLOCKED by supervisor: ${safe_reason}" - sed_append_after "$line_num" "$notes_line" "$todo_file" - fi - - log_success "Updated TODO.md: $task_id marked blocked ($reason)" - - # t296: Post comment to GitHub issue if ref:GH# exists - post_blocked_comment_to_github "$task_id" "$reason" "$trepo" 2>>"${SUPERVISOR_LOG:-/dev/null}" || true - - commit_and_push_todo "$trepo" "chore: mark $task_id blocked in TODO.md" - return $? -} ####################################### # Send notification about task state change @@ -13336,148 +12466,6 @@ run_session_review() { return 0 } -####################################### -# Command: release - manually trigger a release for a batch (t128.10) -# Can also enable/disable release_on_complete for an existing batch -####################################### -cmd_release() { - local batch_id="" release_type="" enable_flag="" dry_run="false" - - if [[ $# -gt 0 && ! "$1" =~ ^-- ]]; then - batch_id="$1" - shift - fi - - while [[ $# -gt 0 ]]; do - case "$1" in - --type) - [[ $# -lt 2 ]] && { - log_error "--type requires a value" - return 1 - } - release_type="$2" - shift 2 - ;; - --enable) - enable_flag="enable" - shift - ;; - --disable) - enable_flag="disable" - shift - ;; - --dry-run) - dry_run="true" - shift - ;; - *) - log_error "Unknown option: $1" - return 1 - ;; - esac - done - - if [[ -z "$batch_id" ]]; then - # Find the most recently completed batch - ensure_db - batch_id=$(db "$SUPERVISOR_DB" " - SELECT id FROM batches WHERE status = 'complete' - ORDER BY updated_at DESC LIMIT 1; - " 2>/dev/null || echo "") - - if [[ -z "$batch_id" ]]; then - log_error "No batch specified and no completed batches found." - log_error "Usage: supervisor-helper.sh release [--type patch|minor|major] [--enable|--disable] [--dry-run]" - return 1 - fi - log_info "Using most recently completed batch: $batch_id" - fi - - ensure_db - - local escaped_batch - escaped_batch=$(sql_escape "$batch_id") - - # Look up batch (by ID or name) - local batch_row - batch_row=$(db -separator '|' "$SUPERVISOR_DB" " - SELECT id, name, status, release_on_complete, release_type - FROM batches WHERE id = '$escaped_batch' OR name = '$escaped_batch' - LIMIT 1; - ") - - if [[ -z "$batch_row" ]]; then - log_error "Batch not found: $batch_id" - return 1 - fi - - local bid bname bstatus brelease_flag brelease_type - IFS='|' read -r bid bname bstatus brelease_flag brelease_type <<<"$batch_row" - escaped_batch=$(sql_escape "$bid") - - # Handle enable/disable mode - if [[ -n "$enable_flag" ]]; then - if [[ "$enable_flag" == "enable" ]]; then - local new_type="${release_type:-${brelease_type:-patch}}" - db "$SUPERVISOR_DB" " - UPDATE batches SET release_on_complete = 1, release_type = '$(sql_escape "$new_type")', - updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') - WHERE id = '$escaped_batch'; - " - log_success "Enabled release_on_complete for batch $bname (type: $new_type)" - else - db "$SUPERVISOR_DB" " - UPDATE batches SET release_on_complete = 0, - updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') - WHERE id = '$escaped_batch'; - " - log_success "Disabled release_on_complete for batch $bname" - fi - return 0 - fi - - # Manual release trigger mode - if [[ -z "$release_type" ]]; then - release_type="${brelease_type:-patch}" - fi - - # Validate release_type - case "$release_type" in - major | minor | patch) ;; - *) - log_error "Invalid release type: $release_type" - return 1 - ;; - esac - - # Get repo from first task in batch - local batch_repo - batch_repo=$(db "$SUPERVISOR_DB" " - SELECT t.repo FROM batch_tasks bt - JOIN tasks t ON bt.task_id = t.id - WHERE bt.batch_id = '$escaped_batch' - ORDER BY bt.position LIMIT 1; - " 2>/dev/null || echo "") - - if [[ -z "$batch_repo" ]]; then - log_error "No tasks found in batch $bname - cannot determine repo" - return 1 - fi - - echo -e "${BOLD}=== Batch Release: $bname ===${NC}" - echo " Batch: $bid" - echo " Status: $bstatus" - echo " Type: $release_type" - echo " Repo: $batch_repo" - - if [[ "$dry_run" == "true" ]]; then - log_info "[dry-run] Would trigger $release_type release for batch $bname from $batch_repo" - return 0 - fi - - trigger_batch_release "$bid" "$release_type" "$batch_repo" - return $? -} ####################################### # Command: retrospective - run batch retrospective @@ -13551,160 +12539,7 @@ cmd_recall() { ####################################### # Command: update-todo - manually trigger TODO.md update for a task -####################################### -cmd_update_todo() { - local task_id="" - - if [[ $# -gt 0 && ! "$1" =~ ^-- ]]; then - task_id="$1" - shift - fi - - if [[ -z "$task_id" ]]; then - log_error "Usage: supervisor-helper.sh update-todo " - return 1 - fi - - ensure_db - - local escaped_id - escaped_id=$(sql_escape "$task_id") - local tstatus - tstatus=$(db "$SUPERVISOR_DB" "SELECT status FROM tasks WHERE id = '$escaped_id';") - - if [[ -z "$tstatus" ]]; then - log_error "Task not found: $task_id" - return 1 - fi - - case "$tstatus" in - complete | deployed | merged | verified) - update_todo_on_complete "$task_id" - ;; - blocked) - local terror - terror=$(db "$SUPERVISOR_DB" "SELECT error FROM tasks WHERE id = '$escaped_id';") - update_todo_on_blocked "$task_id" "${terror:-blocked by supervisor}" - ;; - failed) - local terror - terror=$(db "$SUPERVISOR_DB" "SELECT error FROM tasks WHERE id = '$escaped_id';") - update_todo_on_blocked "$task_id" "FAILED: ${terror:-unknown}" - ;; - *) - log_warn "Task $task_id is in '$tstatus' state - TODO update only applies to complete/deployed/merged/blocked/failed tasks" - return 1 - ;; - esac - - return 0 -} - -####################################### -# Command: reconcile-todo - bulk-update TODO.md for all completed/deployed tasks -# Finds tasks in supervisor DB that are complete/deployed/merged but still -# show as open [ ] in TODO.md, and updates them. -# Handles the case where concurrent push failures left TODO.md stale. -####################################### -cmd_reconcile_todo() { - local repo_path="" - local dry_run="false" - local batch_id="" - - while [[ $# -gt 0 ]]; do - case "$1" in - --repo) - repo_path="$2" - shift 2 - ;; - --batch) - batch_id="$2" - shift 2 - ;; - --dry-run) - dry_run="true" - shift - ;; - *) shift ;; - esac - done - - ensure_db - - # Find completed/deployed/merged/verified tasks - local where_clause="t.status IN ('complete', 'deployed', 'merged', 'verified')" - if [[ -n "$batch_id" ]]; then - local escaped_batch - escaped_batch=$(sql_escape "$batch_id") - where_clause="$where_clause AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.task_id = t.id AND bt.batch_id = '$escaped_batch')" - fi - - local completed_tasks - completed_tasks=$(db -separator '|' "$SUPERVISOR_DB" " - SELECT t.id, t.repo, t.pr_url FROM tasks t - WHERE $where_clause - ORDER BY t.id; - ") - - if [[ -z "$completed_tasks" ]]; then - log_info "No completed tasks found in supervisor DB" - return 0 - fi - local stale_count=0 - local updated_count=0 - local stale_tasks="" - - while IFS='|' read -r tid trepo tpr_url; do - [[ -z "$tid" ]] && continue - - # Use provided repo or task's repo - local check_repo="${repo_path:-$trepo}" - local todo_file="$check_repo/TODO.md" - - if [[ ! -f "$todo_file" ]]; then - continue - fi - - # Check if task is still open in TODO.md - if grep -qE "^[[:space:]]*- \[ \] ${tid}( |$)" "$todo_file"; then - stale_count=$((stale_count + 1)) - stale_tasks="${stale_tasks}${stale_tasks:+, }${tid}" - - if [[ "$dry_run" == "true" ]]; then - log_warn "[dry-run] $tid: deployed in DB but open in TODO.md" - else - log_info "Reconciling $tid..." - - # t260: Attempt PR discovery if pr_url is missing before calling update_todo_on_complete - if [[ -z "$tpr_url" || "$tpr_url" == "no_pr" || "$tpr_url" == "task_only" || "$tpr_url" == "task_obsolete" ]]; then - log_verbose " $tid: Attempting PR discovery before reconciliation" - link_pr_to_task "$tid" --caller "reconcile_todo" 2>>"${SUPERVISOR_LOG:-/dev/null}" || true - fi - - if update_todo_on_complete "$tid"; then - updated_count=$((updated_count + 1)) - else - log_warn "Failed to reconcile $tid" - fi - fi - fi - done <<<"$completed_tasks" - - if [[ "$stale_count" -eq 0 ]]; then - log_success "TODO.md is in sync with supervisor DB (no stale tasks)" - elif [[ "$dry_run" == "true" ]]; then - log_warn "$stale_count stale task(s) found: $stale_tasks" - log_info "Run without --dry-run to fix" - else - log_success "Reconciled $updated_count/$stale_count stale tasks" - if [[ "$updated_count" -lt "$stale_count" ]]; then - log_warn "$((stale_count - updated_count)) task(s) could not be reconciled" - fi - fi - - return 0 -} ####################################### # Command: notify - manually send notification for a task diff --git a/.agents/scripts/supervisor/release.sh b/.agents/scripts/supervisor/release.sh index f1e2bb1937..cefc14735b 100644 --- a/.agents/scripts/supervisor/release.sh +++ b/.agents/scripts/supervisor/release.sh @@ -2,22 +2,288 @@ # release.sh - Supervisor release management functions # Part of the AI DevOps Framework supervisor module -# Check if a release is needed based on merged PRs -check_release_needed() { - : -} +# Trigger batch release (t128.10) +# Runs version-manager.sh to bump version, update changelog, tag, and create GitHub release +trigger_batch_release() { + local batch_id="$1" + local release_type="$2" + local repo="$3" -# Determine release type (major/minor/patch) from commits -determine_release_type() { - : -} + local version_manager="${SCRIPT_DIR}/version-manager.sh" + if [[ ! -x "$version_manager" ]]; then + log_error "version-manager.sh not found or not executable: $version_manager" + return 1 + fi + + if [[ -z "$repo" || ! -d "$repo" ]]; then + log_error "Invalid repo path for batch release: $repo" + return 1 + fi + + # Validate release_type + case "$release_type" in + major | minor | patch) ;; + *) + log_error "Invalid release type for batch $batch_id: $release_type" + return 1 + ;; + esac + + local escaped_batch + escaped_batch=$(sql_escape "$batch_id") + local batch_name + batch_name=$(db "$SUPERVISOR_DB" "SELECT name FROM batches WHERE id = '$escaped_batch';" 2>/dev/null || echo "$batch_id") + + # Gather batch stats for the release log + local total_tasks complete_count failed_count + total_tasks=$(db "$SUPERVISOR_DB" " + SELECT count(*) FROM batch_tasks WHERE batch_id = '$escaped_batch'; + ") + complete_count=$(db "$SUPERVISOR_DB" " + SELECT count(*) FROM batch_tasks bt JOIN tasks t ON bt.task_id = t.id + WHERE bt.batch_id = '$escaped_batch' AND t.status IN ('complete', 'deployed', 'merged'); + ") + failed_count=$(db "$SUPERVISOR_DB" " + SELECT count(*) FROM batch_tasks bt JOIN tasks t ON bt.task_id = t.id + WHERE bt.batch_id = '$escaped_batch' AND t.status IN ('failed', 'blocked'); + ") + + log_info "Triggering $release_type release for batch $batch_name ($complete_count/$total_tasks tasks complete, $failed_count failed)" + + # Release must run from the main repo on the main branch + # version-manager.sh handles: bump, update files, changelog, tag, push, GitHub release + local release_log + release_log="$SUPERVISOR_DIR/logs/release-${batch_id}-$(date +%Y%m%d%H%M%S).log" + mkdir -p "$SUPERVISOR_DIR/logs" + + # Ensure we're on main and in sync before releasing + local current_branch + current_branch=$(git -C "$repo" branch --show-current 2>/dev/null || echo "") + if [[ "$current_branch" != "main" ]]; then + log_warn "Repo not on main branch (on: $current_branch), switching..." + git -C "$repo" checkout main 2>/dev/null || { + log_error "Failed to switch to main branch for release" + return 1 + } + fi + + # t276: Stash any dirty working tree before release. + # Common cause: todo/VERIFY.md, untracked files from parallel sessions. + # version-manager.sh refuses to release with uncommitted changes. + local stashed=false + if [[ -n "$(git -C "$repo" status --porcelain 2>/dev/null)" ]]; then + log_info "Stashing dirty working tree before release..." + if git -C "$repo" stash push -m "auto-release-stash-$(date +%Y%m%d%H%M%S)" 2>/dev/null; then + stashed=true + else + log_warn "git stash failed, proceeding anyway (release may fail)" + fi + fi + + # Pull latest (all batch PRs should be merged by now) + git -C "$repo" pull --ff-only origin main 2>/dev/null || { + log_warn "Fast-forward pull failed, trying rebase..." + git -C "$repo" pull --rebase origin main 2>/dev/null || { + log_error "Failed to pull latest main for release" + [[ "$stashed" == "true" ]] && git -C "$repo" stash pop 2>/dev/null || true + return 1 + } + } + + # Run the release (--skip-preflight: batch tasks already passed CI individually) + # Use --force to bypass empty CHANGELOG check (auto-generates from commits) + local release_output="" + local release_exit=0 + release_output=$(cd "$repo" && bash "$version_manager" release "$release_type" --skip-preflight --force 2>&1) || release_exit=$? + + # t276: Restore stashed changes after release (regardless of success/failure) + if [[ "$stashed" == "true" ]]; then + log_info "Restoring stashed working tree..." + git -C "$repo" stash pop 2>/dev/null || log_warn "git stash pop failed (may need manual recovery)" + fi + + echo "$release_output" >"$release_log" 2>/dev/null || true + + if [[ "$release_exit" -ne 0 ]]; then + log_error "Release failed for batch $batch_name (exit: $release_exit)" + log_error "See log: $release_log" + # Store failure in memory for future reference + if [[ -x "$MEMORY_HELPER" ]]; then + "$MEMORY_HELPER" store \ + --auto \ + --type "FAILED_APPROACH" \ + --content "Batch release failed: $batch_name ($release_type). Exit: $release_exit. Check $release_log" \ + --tags "supervisor,release,batch,$batch_name,failed" \ + 2>/dev/null || true + fi + # Send notification about release failure + send_task_notification "batch-$batch_id" "failed" "Batch release ($release_type) failed for $batch_name" 2>/dev/null || true + return 1 + fi + + # Extract the new version from the release output + local new_version + new_version=$(echo "$release_output" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | tail -1 || echo "unknown") + + log_success "Release $new_version created for batch $batch_name ($release_type)" + + # Store success in memory + if [[ -x "$MEMORY_HELPER" ]]; then + "$MEMORY_HELPER" store \ + --auto \ + --type "WORKING_SOLUTION" \ + --content "Batch release succeeded: $batch_name -> v$new_version ($release_type). $complete_count/$total_tasks tasks, $failed_count failed." \ + --tags "supervisor,release,batch,$batch_name,success,v$new_version" \ + 2>/dev/null || true + fi + + # Send notification about successful release + send_task_notification "batch-$batch_id" "deployed" "Released v$new_version ($release_type) for batch $batch_name" 2>/dev/null || true -# Create release PR -create_release_pr() { - : + # macOS celebration notification + if [[ "$(uname)" == "Darwin" ]]; then + nohup afplay /System/Library/Sounds/Hero.aiff &>/dev/null & + fi + + return 0 } -# Execute release workflow -execute_release() { - : +# Command handler for manual release trigger +cmd_release() { + local batch_id="" release_type="" enable_flag="" dry_run="false" + + if [[ $# -gt 0 && ! "$1" =~ ^-- ]]; then + batch_id="$1" + shift + fi + + while [[ $# -gt 0 ]]; do + case "$1" in + --type) + [[ $# -lt 2 ]] && { + log_error "--type requires a value" + return 1 + } + release_type="$2" + shift 2 + ;; + --enable) + enable_flag="enable" + shift + ;; + --disable) + enable_flag="disable" + shift + ;; + --dry-run) + dry_run="true" + shift + ;; + *) + log_error "Unknown option: $1" + return 1 + ;; + esac + done + + if [[ -z "$batch_id" ]]; then + # Find the most recently completed batch + ensure_db + batch_id=$(db "$SUPERVISOR_DB" " + SELECT id FROM batches WHERE status = 'complete' + ORDER BY updated_at DESC LIMIT 1; + " 2>/dev/null || echo "") + + if [[ -z "$batch_id" ]]; then + log_error "No batch specified and no completed batches found." + log_error "Usage: supervisor-helper.sh release [--type patch|minor|major] [--enable|--disable] [--dry-run]" + return 1 + fi + log_info "Using most recently completed batch: $batch_id" + fi + + ensure_db + + local escaped_batch + escaped_batch=$(sql_escape "$batch_id") + + # Look up batch (by ID or name) + local batch_row + batch_row=$(db -separator '|' "$SUPERVISOR_DB" " + SELECT id, name, status, release_on_complete, release_type + FROM batches WHERE id = '$escaped_batch' OR name = '$escaped_batch' + LIMIT 1; + ") + + if [[ -z "$batch_row" ]]; then + log_error "Batch not found: $batch_id" + return 1 + fi + + local bid bname bstatus _brelease_flag brelease_type + IFS='|' read -r bid bname bstatus _brelease_flag brelease_type <<<"$batch_row" + escaped_batch=$(sql_escape "$bid") + + # Handle enable/disable mode + if [[ -n "$enable_flag" ]]; then + if [[ "$enable_flag" == "enable" ]]; then + local new_type="${release_type:-${brelease_type:-patch}}" + db "$SUPERVISOR_DB" " + UPDATE batches SET release_on_complete = 1, release_type = '$(sql_escape "$new_type")', + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') + WHERE id = '$escaped_batch'; + " + log_success "Enabled release_on_complete for batch $bname (type: $new_type)" + else + db "$SUPERVISOR_DB" " + UPDATE batches SET release_on_complete = 0, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') + WHERE id = '$escaped_batch'; + " + log_success "Disabled release_on_complete for batch $bname" + fi + return 0 + fi + + # Manual release trigger mode + if [[ -z "$release_type" ]]; then + release_type="${brelease_type:-patch}" + fi + + # Validate release_type + case "$release_type" in + major | minor | patch) ;; + *) + log_error "Invalid release type: $release_type" + return 1 + ;; + esac + + # Get repo from first task in batch + local batch_repo + batch_repo=$(db "$SUPERVISOR_DB" " + SELECT t.repo FROM batch_tasks bt + JOIN tasks t ON bt.task_id = t.id + WHERE bt.batch_id = '$escaped_batch' + ORDER BY bt.position LIMIT 1; + " 2>/dev/null || echo "") + + if [[ -z "$batch_repo" ]]; then + log_error "No tasks found in batch $bname - cannot determine repo" + return 1 + fi + + echo -e "${BOLD}=== Batch Release: $bname ===${NC}" + echo " Batch: $bid" + echo " Status: $bstatus" + echo " Type: $release_type" + echo " Repo: $batch_repo" + + if [[ "$dry_run" == "true" ]]; then + log_info "[dry-run] Would trigger $release_type release for batch $bname from $batch_repo" + return 0 + fi + + trigger_batch_release "$bid" "$release_type" "$batch_repo" + return $? } diff --git a/.agents/scripts/supervisor/todo-sync.sh b/.agents/scripts/supervisor/todo-sync.sh index 665ba4bc17..cbb1b14b07 100644 --- a/.agents/scripts/supervisor/todo-sync.sh +++ b/.agents/scripts/supervisor/todo-sync.sh @@ -2,27 +2,809 @@ # todo-sync.sh - Supervisor TODO.md synchronization functions # Part of the AI DevOps Framework supervisor module -# Sync task state to TODO.md -sync_task_to_todo() { - : +####################################### +# Commit and push TODO.md with pull-rebase retry +# Handles concurrent push conflicts from parallel workers +# Args: $1=repo_path $2=commit_message $3=max_retries (default 3) +####################################### +commit_and_push_todo() { + local repo_path="$1" + local commit_msg="$2" + local max_retries="${3:-3}" + + if git -C "$repo_path" diff --quiet -- TODO.md 2>>"$SUPERVISOR_LOG"; then + log_info "No changes to commit (TODO.md unchanged)" + return 0 + fi + + git -C "$repo_path" add TODO.md + + local attempt=0 + while [[ "$attempt" -lt "$max_retries" ]]; do + attempt=$((attempt + 1)) + + # Pull-rebase to incorporate any concurrent TODO.md pushes + if ! git -C "$repo_path" pull --rebase --autostash 2>>"$SUPERVISOR_LOG"; then + log_warn "Pull-rebase failed (attempt $attempt/$max_retries)" + # Abort rebase if in progress and retry + git -C "$repo_path" rebase --abort 2>>"$SUPERVISOR_LOG" || true + sleep "$attempt" + continue + fi + + # Re-stage TODO.md (rebase may have resolved it) + if ! git -C "$repo_path" diff --quiet -- TODO.md 2>>"$SUPERVISOR_LOG"; then + git -C "$repo_path" add TODO.md + fi + + # Check if our change survived the rebase (may have been applied by another worker) + if git -C "$repo_path" diff --cached --quiet -- TODO.md 2>>"$SUPERVISOR_LOG"; then + log_info "TODO.md change already applied (likely by another worker)" + return 0 + fi + + # Commit + if ! git -C "$repo_path" commit -m "$commit_msg" -- TODO.md 2>>"$SUPERVISOR_LOG"; then + log_warn "Commit failed (attempt $attempt/$max_retries)" + sleep "$attempt" + continue + fi + + # Push + if git -C "$repo_path" push 2>>"$SUPERVISOR_LOG"; then + log_success "Committed and pushed TODO.md update" + return 0 + fi + + log_warn "Push failed (attempt $attempt/$max_retries) - will pull-rebase and retry" + sleep "$attempt" + done + + log_error "Failed to push TODO.md after $max_retries attempts" + return 1 } -# Update TODO.md with batch progress -update_todo_batch_progress() { - : +####################################### +# Populate VERIFY.md queue after PR merge (t180.2) +# Extracts changed files from the PR and generates check: directives +# based on file types (shellcheck for .sh, file-exists for new files, etc.) +# Appends a new entry to the VERIFY-QUEUE in todo/VERIFY.md +####################################### +populate_verify_queue() { + local task_id="$1" + local pr_url="${2:-}" + local repo="${3:-}" + + if [[ -z "$repo" ]]; then + log_warn "populate_verify_queue: no repo for $task_id" + return 1 + fi + + local verify_file="$repo/todo/VERIFY.md" + if [[ ! -f "$verify_file" ]]; then + log_info "No VERIFY.md at $verify_file — skipping verify queue population" + return 0 + fi + + # Extract PR number and repo slug (t232) + local parsed_populate pr_number repo_slug + parsed_populate=$(parse_pr_url "$pr_url") || parsed_populate="" + if [[ -z "$parsed_populate" ]]; then + log_warn "populate_verify_queue: cannot parse PR URL for $task_id: $pr_url" + return 1 + fi + repo_slug="${parsed_populate%%|*}" + pr_number="${parsed_populate##*|}" + + # Check if this task already has a verify entry (idempotency) + if grep -q "^- \[.\] v[0-9]* $task_id " "$verify_file" 2>/dev/null; then + log_info "Verify entry already exists for $task_id in VERIFY.md" + return 0 + fi + + # Get changed files from PR + local changed_files + if ! changed_files=$(gh pr view "$pr_number" --repo "$repo_slug" --json files --jq '.files[].path' 2>>"$SUPERVISOR_LOG"); then + log_warn "populate_verify_queue: failed to fetch PR files for $task_id (#$pr_number)" + return 1 + fi + + if [[ -z "$changed_files" ]]; then + log_info "No files changed in PR #$pr_number for $task_id" + return 0 + fi + + # Filter to substantive files (skip TODO.md, planning files) + local substantive_files + substantive_files=$(echo "$changed_files" | grep -vE '^(TODO\.md$|todo/)' || true) + + if [[ -z "$substantive_files" ]]; then + log_info "No substantive files in PR #$pr_number for $task_id — skipping verify" + return 0 + fi + + # Get task description from DB + local task_desc + task_desc=$(db "$SUPERVISOR_DB" "SELECT description FROM tasks WHERE id = '$(sql_escape "$task_id")';" 2>/dev/null || echo "$task_id") + # Truncate long descriptions + if [[ ${#task_desc} -gt 60 ]]; then + task_desc="${task_desc:0:57}..." + fi + + # Determine next verify ID + local last_vnum + last_vnum=$(grep -oE 'v[0-9]+' "$verify_file" | grep -oE '[0-9]+' | sort -n | tail -1 || echo "0") + last_vnum=$((10#$last_vnum)) + local next_vnum=$((last_vnum + 1)) + local verify_id + verify_id=$(printf "v%03d" "$next_vnum") + + local today + today=$(date +%Y-%m-%d) + + # Build the verify entry + local entry="" + entry+="- [ ] $verify_id $task_id $task_desc | PR #$pr_number | merged:$today" + entry+=$'\n' + entry+=" files: $(echo "$substantive_files" | tr '\n' ',' | sed 's/,$//' | sed 's/,/, /g')" + + # Generate check directives based on file types + local checks="" + while IFS= read -r file; do + [[ -z "$file" ]] && continue + case "$file" in + *.sh) + checks+=$'\n'" check: shellcheck $file" + checks+=$'\n'" check: file-exists $file" + ;; + *.md) + checks+=$'\n'" check: file-exists $file" + ;; + *.toon) + checks+=$'\n'" check: file-exists $file" + ;; + *.yml | *.yaml) + checks+=$'\n'" check: file-exists $file" + ;; + *.json) + checks+=$'\n'" check: file-exists $file" + ;; + *) + checks+=$'\n'" check: file-exists $file" + ;; + esac + done <<<"$substantive_files" + + # Also add subagent-index check if any .md files in .agents/ were changed + if echo "$substantive_files" | grep -qE '\.agents/.*\.md$'; then + local base_names + base_names=$(echo "$substantive_files" | grep -E '\.agents/.*\.md$' | xargs -I{} basename {} .md || true) + while IFS= read -r bname; do + [[ -z "$bname" ]] && continue + # Only check for subagent-index entries for tool/service/workflow files + if echo "$substantive_files" | grep -qE "\.agents/(tools|services|workflows)/.*${bname}\.md$"; then + checks+=$'\n'" check: rg \"$bname\" .agents/subagent-index.toon" + fi + done <<<"$base_names" + fi + + entry+="$checks" + + # Append to VERIFY.md before the end marker + if grep -q '' "$verify_file"; then + # Insert before the end marker + local temp_file + temp_file=$(mktemp) + _save_cleanup_scope + trap '_run_cleanups' RETURN + push_cleanup "rm -f '${temp_file}'" + awk -v entry="$entry" ' + // { + print entry + print "" + } + { print } + ' "$verify_file" >"$temp_file" + mv "$temp_file" "$verify_file" + else + # No end marker — append to end of file + echo "" >>"$verify_file" + echo "$entry" >>"$verify_file" + fi + + log_success "Added verify entry $verify_id for $task_id to VERIFY.md" + return 0 } -# Mark task complete in TODO.md -mark_todo_complete() { - : +####################################### +# Mark a verify entry as passed [x] or failed [!] in VERIFY.md (t180.3) +####################################### +mark_verify_entry() { + local verify_file="$1" + local task_id="$2" + local result="$3" + local today="${4:-$(date +%Y-%m-%d)}" + local reason="${5:-}" + + if [[ "$result" == "pass" ]]; then + # Mark [x] and add verified:date + sed -i.bak "s/^- \[ \] \(v[0-9]* $task_id .*\)/- [x] \1 verified:$today/" "$verify_file" + else + # Mark [!] and add failed:date reason:description + local escaped_reason + escaped_reason=$(echo "$reason" | sed 's/[&/\]/\\&/g' | head -c 200) + sed -i.bak "s/^- \[ \] \(v[0-9]* $task_id .*\)/- [!] \1 failed:$today reason:$escaped_reason/" "$verify_file" + fi + rm -f "${verify_file}.bak" + + return 0 } -# Add task to TODO.md -add_task_to_todo() { - : +####################################### +# Process verification queue — run checks for deployed tasks (t180.3) +# Scans VERIFY.md for pending entries, runs checks, updates states +# Called from pulse Phase 6 +####################################### +process_verify_queue() { + local batch_id="${1:-}" + + ensure_db + + # Find deployed tasks that need verification + local deployed_tasks + local where_clause="t.status = 'deployed'" + if [[ -n "$batch_id" ]]; then + where_clause="$where_clause AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.task_id = t.id AND bt.batch_id = '$(sql_escape "$batch_id")')" + fi + + deployed_tasks=$(db -separator '|' "$SUPERVISOR_DB" " + SELECT t.id, t.repo, t.pr_url FROM tasks t + WHERE $where_clause + ORDER BY t.updated_at ASC; + ") + + if [[ -z "$deployed_tasks" ]]; then + return 0 + fi + + local verified_count=0 + local failed_count=0 + + while IFS='|' read -r tid trepo _tpr; do + [[ -z "$tid" ]] && continue + + local verify_file="$trepo/todo/VERIFY.md" + if [[ ! -f "$verify_file" ]]; then + continue + fi + + # Check if there's a pending verify entry for this task + if ! grep -q "^- \[ \] v[0-9]* $tid " "$verify_file" 2>/dev/null; then + continue + fi + + log_info " $tid: running verification checks" + cmd_transition "$tid" "verifying" 2>>"$SUPERVISOR_LOG" || { + log_warn " $tid: failed to transition to verifying" + continue + } + + if run_verify_checks "$tid" "$trepo"; then + cmd_transition "$tid" "verified" 2>>"$SUPERVISOR_LOG" || true + verified_count=$((verified_count + 1)) + log_success " $tid: VERIFIED" + else + cmd_transition "$tid" "verify_failed" 2>>"$SUPERVISOR_LOG" || true + failed_count=$((failed_count + 1)) + log_warn " $tid: VERIFY FAILED" + send_task_notification "$tid" "verify_failed" "Post-merge verification failed" 2>>"$SUPERVISOR_LOG" || true + fi + done <<<"$deployed_tasks" + + if [[ $((verified_count + failed_count)) -gt 0 ]]; then + log_info "Verification: $verified_count passed, $failed_count failed" + fi + + return 0 +} + +####################################### +# Commit and push VERIFY.md changes after verification (t180.3) +####################################### +commit_verify_changes() { + local repo="$1" + local task_id="$2" + local result="$3" + + local verify_file="$repo/todo/VERIFY.md" + if [[ ! -f "$verify_file" ]]; then + return 0 + fi + + # Check if there are changes to commit + if ! git -C "$repo" diff --quiet -- "todo/VERIFY.md" 2>/dev/null; then + local msg="chore: mark $task_id verification $result in VERIFY.md [skip ci]" + git -C "$repo" add "todo/VERIFY.md" 2>>"$SUPERVISOR_LOG" || return 1 + git -C "$repo" commit -m "$msg" 2>>"$SUPERVISOR_LOG" || return 1 + git -C "$repo" push origin main 2>>"$SUPERVISOR_LOG" || return 1 + log_info "Committed VERIFY.md update for $task_id ($result)" + fi + + return 0 } -# Parse TODO.md for task metadata -parse_todo_metadata() { - : +####################################### +# Update TODO.md when a task completes +# Marks the task checkbox as [x], adds completed:YYYY-MM-DD +# Then commits and pushes the change +# Guard (t163): requires verified deliverables before marking [x] +####################################### +update_todo_on_complete() { + local task_id="$1" + + ensure_db + + local escaped_id + escaped_id=$(sql_escape "$task_id") + local task_row + task_row=$(db -separator '|' "$SUPERVISOR_DB" " + SELECT repo, description, pr_url FROM tasks WHERE id = '$escaped_id'; + ") + + if [[ -z "$task_row" ]]; then + log_error "Task not found: $task_id" + return 1 + fi + + local trepo tdesc tpr_url + IFS='|' read -r trepo tdesc tpr_url <<<"$task_row" + + # Verify deliverables before marking complete (t163.4) + if ! verify_task_deliverables "$task_id" "$tpr_url" "$trepo"; then + log_warn "Task $task_id failed deliverable verification - NOT marking [x] in TODO.md" + log_warn " To manually verify: add 'verified:$(date +%Y-%m-%d)' to the task line" + return 1 + fi + + local todo_file="$trepo/TODO.md" + if [[ ! -f "$todo_file" ]]; then + log_warn "TODO.md not found at $todo_file" + return 1 + fi + + # t278: Guard against marking #plan tasks complete when subtasks are still open. + # A #plan task is a parent that was decomposed into subtasks. It should only be + # marked [x] when ALL its subtasks are [x]. This prevents decomposition workers + # from prematurely completing the parent. + local task_line + task_line=$(grep -E "^[[:space:]]*- \[[ x-]\] ${task_id}( |$)" "$todo_file" | head -1 || true) + if [[ -n "$task_line" && "$task_line" == *"#plan"* ]]; then + # Get the indentation level of this task + local task_indent + task_indent=$(echo "$task_line" | sed -E 's/^([[:space:]]*).*/\1/' | wc -c) + task_indent=$((task_indent - 1)) # wc -c counts newline + + # Check for open subtasks (lines indented deeper with [ ]) + local open_subtasks + open_subtasks=$(awk -v tid="$task_id" -v tindent="$task_indent" ' + BEGIN { found=0 } + /- \[[ x-]\] '"$task_id"'( |$)/ { found=1; next } + found && /^[[:space:]]*- \[/ { + # Count leading spaces + match($0, /^[[:space:]]*/); + line_indent = RLENGTH; + if (line_indent > tindent) { + if ($0 ~ /- \[ \]/) { print $0 } + } else { found=0 } + } + found && /^[[:space:]]*$/ { next } + found && !/^[[:space:]]*- / && !/^[[:space:]]*$/ { found=0 } + ' "$todo_file") + + if [[ -n "$open_subtasks" ]]; then + local open_count + open_count=$(echo "$open_subtasks" | wc -l | tr -d ' ') + log_warn "Task $task_id is a #plan task with $open_count open subtask(s) — NOT marking [x]" + log_warn " Parent #plan tasks should only be completed when all subtasks are done" + return 1 + fi + fi + + local today + today=$(date +%Y-%m-%d) + + # Match the task line (open checkbox with task ID) + # Handles both top-level and indented subtasks + if ! grep -qE "^[[:space:]]*- \[ \] ${task_id}( |$)" "$todo_file"; then + log_warn "Task $task_id not found as open in $todo_file (may already be completed)" + return 0 + fi + + # Mark as complete: [ ] -> [x], append completed:date + # Use sed to match the line and transform it + local sed_pattern="s/^([[:space:]]*- )\[ \] (${task_id} .*)$/\1[x] \2 completed:${today}/" + + sed_inplace -E "$sed_pattern" "$todo_file" + + # Verify the change was made + if ! grep -qE "^[[:space:]]*- \[x\] ${task_id} " "$todo_file"; then + log_error "Failed to update TODO.md for $task_id" + return 1 + fi + + log_success "Updated TODO.md: $task_id marked complete ($today)" + + local commit_msg="chore: mark $task_id complete in TODO.md" + if [[ -n "$tpr_url" ]]; then + commit_msg="chore: mark $task_id complete in TODO.md (${tpr_url})" + fi + commit_and_push_todo "$trepo" "$commit_msg" + return $? +} + +####################################### +# Generate a VERIFY.md entry for a deployed task (t180.4) +# Auto-creates check directives based on PR files: +# - .sh files: shellcheck + bash -n + file-exists +# - .md files: file-exists +# - test files: bash +# - other: file-exists +# Appends entry before marker +# $1: task_id +####################################### +generate_verify_entry() { + local task_id="$1" + + ensure_db + + local escaped_id + escaped_id=$(sql_escape "$task_id") + local task_row + task_row=$(db -separator '|' "$SUPERVISOR_DB" " + SELECT repo, description, pr_url FROM tasks WHERE id = '$escaped_id'; + ") + + if [[ -z "$task_row" ]]; then + log_warn "generate_verify_entry: task not found: $task_id" + return 1 + fi + + local trepo tdesc tpr_url + IFS='|' read -r trepo tdesc tpr_url <<<"$task_row" + + local verify_file="$trepo/todo/VERIFY.md" + if [[ ! -f "$verify_file" ]]; then + log_warn "generate_verify_entry: VERIFY.md not found at $verify_file" + return 1 + fi + + # Check if entry already exists for this task + local task_id_escaped + task_id_escaped=$(printf '%s' "$task_id" | sed 's/\./\\./g') + if grep -qE "^- \[.\] v[0-9]+ ${task_id_escaped} " "$verify_file"; then + log_info "generate_verify_entry: entry already exists for $task_id" + return 0 + fi + + # Get next vNNN number + local last_v + last_v=$(grep -oE '^- \[.\] v([0-9]+)' "$verify_file" | grep -oE '[0-9]+' | sort -n | tail -1 || echo "0") + last_v=$((10#$last_v)) + local next_v=$((last_v + 1)) + local vid + vid=$(printf "v%03d" "$next_v") + + # Extract PR number + local pr_number="" + if [[ "$tpr_url" =~ /pull/([0-9]+) ]]; then + pr_number="${BASH_REMATCH[1]}" + fi + + local today + today=$(date +%Y-%m-%d) + + # Get files changed in PR (requires gh CLI) + local files_list="" + local -a check_lines=() + + if [[ -n "$pr_number" ]] && command -v gh &>/dev/null && check_gh_auth; then + local repo_slug="" + repo_slug=$(detect_repo_slug "$trepo" 2>/dev/null || echo "") + if [[ -n "$repo_slug" ]]; then + files_list=$(gh pr view "$pr_number" --repo "$repo_slug" --json files --jq '.files[].path' 2>/dev/null | tr '\n' ', ' | sed 's/,$//') + + # Generate check directives based on file types + while IFS= read -r fpath; do + [[ -z "$fpath" ]] && continue + case "$fpath" in + tests/*.sh | test-*.sh) + check_lines+=(" check: bash $fpath") + ;; + *.sh) + check_lines+=(" check: file-exists $fpath") + check_lines+=(" check: shellcheck $fpath") + check_lines+=(" check: bash -n $fpath") + ;; + *.md) + check_lines+=(" check: file-exists $fpath") + ;; + *) + check_lines+=(" check: file-exists $fpath") + ;; + esac + done < <(gh pr view "$pr_number" --repo "$repo_slug" --json files --jq '.files[].path' 2>/dev/null) + fi + fi + + # Fallback: if no checks generated, add basic file-exists for PR + if [[ ${#check_lines[@]} -eq 0 && -n "$pr_number" ]]; then + check_lines+=(" check: rg \"$task_id\" $trepo/TODO.md") + fi + + # Build the entry + local entry_header="- [ ] $vid $task_id ${tdesc%% *} | PR #${pr_number:-unknown} | merged:$today" + local entry_body="" + if [[ -n "$files_list" ]]; then + entry_body+=" files: $files_list"$'\n' + fi + for cl in "${check_lines[@]}"; do + entry_body+="$cl"$'\n' + done + + # Insert before + local marker="" + if ! grep -q "$marker" "$verify_file"; then + log_warn "generate_verify_entry: VERIFY-QUEUE-END marker not found" + return 1 + fi + + # Build full entry text + local full_entry + full_entry=$(printf '%s\n%s\n' "$entry_header" "$entry_body") + + # Insert before marker using temp file (portable across macOS/Linux) + local tmp_file + tmp_file=$(mktemp) + _save_cleanup_scope + trap '_run_cleanups' RETURN + push_cleanup "rm -f '${tmp_file}'" + awk -v entry="$full_entry" -v mark="$marker" '{ + if (index($0, mark) > 0) { print entry; } + print; + }' "$verify_file" >"$tmp_file" && mv "$tmp_file" "$verify_file" + + log_success "Generated verify entry $vid for $task_id (PR #${pr_number:-unknown})" + + # Commit and push + commit_and_push_todo "$trepo" "chore: add verify entry $vid for $task_id" 2>>"$SUPERVISOR_LOG" || true + + return 0 +} + +####################################### +# Update TODO.md when a task is blocked or failed +# Adds Notes line with blocked reason +# Then commits and pushes the change +# t296: Also posts a comment to GitHub issue if ref:GH# exists +####################################### +update_todo_on_blocked() { + local task_id="$1" + local reason="${2:-unknown}" + + ensure_db + + local escaped_id + escaped_id=$(sql_escape "$task_id") + local trepo + trepo=$(db "$SUPERVISOR_DB" "SELECT repo FROM tasks WHERE id = '$escaped_id';") + + if [[ -z "$trepo" ]]; then + log_error "Task not found: $task_id" + return 1 + fi + + local todo_file="$trepo/TODO.md" + if [[ ! -f "$todo_file" ]]; then + log_warn "TODO.md not found at $todo_file" + return 1 + fi + + # Find the task line number + local line_num + line_num=$(grep -nE "^[[:space:]]*- \[ \] ${task_id}( |$)" "$todo_file" | head -1 | cut -d: -f1) + + if [[ -z "$line_num" ]]; then + log_warn "Task $task_id not found as open in $todo_file" + return 0 + fi + + # Detect indentation of the task line for proper Notes alignment + local task_line + task_line=$(sed -n "${line_num}p" "$todo_file") + local indent="" + indent=$(echo "$task_line" | sed -E 's/^([[:space:]]*).*/\1/') + + # Check if a Notes line already exists below the task + local next_line_num=$((line_num + 1)) + local next_line + next_line=$(sed -n "${next_line_num}p" "$todo_file" 2>/dev/null || echo "") + + # Sanitize reason for safe insertion (escape special sed chars) + local safe_reason + safe_reason=$(echo "$reason" | sed 's/[&/\]/\\&/g' | head -c 200) + + if echo "$next_line" | grep -qE "^[[:space:]]*- Notes:"; then + # Append to existing Notes line + local append_text=" BLOCKED: ${safe_reason}" + sed_inplace "${next_line_num}s/$/${append_text}/" "$todo_file" + else + # Insert a new Notes line after the task + local notes_line="${indent} - Notes: BLOCKED by supervisor: ${safe_reason}" + sed_append_after "$line_num" "$notes_line" "$todo_file" + fi + + log_success "Updated TODO.md: $task_id marked blocked ($reason)" + + # t296: Post comment to GitHub issue if ref:GH# exists + post_blocked_comment_to_github "$task_id" "$reason" "$trepo" 2>>"${SUPERVISOR_LOG:-/dev/null}" || true + + commit_and_push_todo "$trepo" "chore: mark $task_id blocked in TODO.md" + return $? +} + +####################################### +# Command: update-todo - manually trigger TODO.md update for a task +####################################### +cmd_update_todo() { + local task_id="" + + if [[ $# -gt 0 && ! "$1" =~ ^-- ]]; then + task_id="$1" + shift + fi + + if [[ -z "$task_id" ]]; then + log_error "Usage: supervisor-helper.sh update-todo " + return 1 + fi + + ensure_db + + local escaped_id + escaped_id=$(sql_escape "$task_id") + local tstatus + tstatus=$(db "$SUPERVISOR_DB" "SELECT status FROM tasks WHERE id = '$escaped_id';") + + if [[ -z "$tstatus" ]]; then + log_error "Task not found: $task_id" + return 1 + fi + + case "$tstatus" in + complete | deployed | merged | verified) + update_todo_on_complete "$task_id" + ;; + blocked) + local terror + terror=$(db "$SUPERVISOR_DB" "SELECT error FROM tasks WHERE id = '$escaped_id';") + update_todo_on_blocked "$task_id" "${terror:-blocked by supervisor}" + ;; + failed) + local terror + terror=$(db "$SUPERVISOR_DB" "SELECT error FROM tasks WHERE id = '$escaped_id';") + update_todo_on_blocked "$task_id" "FAILED: ${terror:-unknown}" + ;; + *) + log_warn "Task $task_id is in '$tstatus' state - TODO update only applies to complete/deployed/merged/blocked/failed tasks" + return 1 + ;; + esac + + return 0 +} + +####################################### +# Command: reconcile-todo - bulk-update TODO.md for all completed/deployed tasks +# Finds tasks in supervisor DB that are complete/deployed/merged but still +# show as open [ ] in TODO.md, and updates them. +# Handles the case where concurrent push failures left TODO.md stale. +####################################### +cmd_reconcile_todo() { + local repo_path="" + local dry_run="false" + local batch_id="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --repo) + repo_path="$2" + shift 2 + ;; + --batch) + batch_id="$2" + shift 2 + ;; + --dry-run) + dry_run="true" + shift + ;; + *) shift ;; + esac + done + + ensure_db + + # Find completed/deployed/merged/verified tasks + local where_clause="t.status IN ('complete', 'deployed', 'merged', 'verified')" + if [[ -n "$batch_id" ]]; then + local escaped_batch + escaped_batch=$(sql_escape "$batch_id") + where_clause="$where_clause AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.task_id = t.id AND bt.batch_id = '$escaped_batch')" + fi + + local completed_tasks + completed_tasks=$(db -separator '|' "$SUPERVISOR_DB" " + SELECT t.id, t.repo, t.pr_url FROM tasks t + WHERE $where_clause + ORDER BY t.id; + ") + + if [[ -z "$completed_tasks" ]]; then + log_info "No completed tasks found in supervisor DB" + return 0 + fi + + local stale_count=0 + local updated_count=0 + local stale_tasks="" + + while IFS='|' read -r tid trepo tpr_url; do + [[ -z "$tid" ]] && continue + + # Use provided repo or task's repo + local check_repo="${repo_path:-$trepo}" + local todo_file="$check_repo/TODO.md" + + if [[ ! -f "$todo_file" ]]; then + continue + fi + + # Check if task is still open in TODO.md + if grep -qE "^[[:space:]]*- \[ \] ${tid}( |$)" "$todo_file"; then + stale_count=$((stale_count + 1)) + stale_tasks="${stale_tasks}${stale_tasks:+, }${tid}" + + if [[ "$dry_run" == "true" ]]; then + log_warn "[dry-run] $tid: deployed in DB but open in TODO.md" + else + log_info "Reconciling $tid..." + + # t260: Attempt PR discovery if pr_url is missing before calling update_todo_on_complete + if [[ -z "$tpr_url" || "$tpr_url" == "no_pr" || "$tpr_url" == "task_only" || "$tpr_url" == "task_obsolete" ]]; then + log_verbose " $tid: Attempting PR discovery before reconciliation" + link_pr_to_task "$tid" --caller "reconcile_todo" 2>>"${SUPERVISOR_LOG:-/dev/null}" || true + fi + + if update_todo_on_complete "$tid"; then + updated_count=$((updated_count + 1)) + else + log_warn "Failed to reconcile $tid" + fi + fi + fi + done <<<"$completed_tasks" + + if [[ "$stale_count" -eq 0 ]]; then + log_success "TODO.md is in sync with supervisor DB (no stale tasks)" + elif [[ "$dry_run" == "true" ]]; then + log_warn "$stale_count stale task(s) found: $stale_tasks" + log_info "Run without --dry-run to fix" + else + log_success "Reconciled $updated_count/$stale_count stale tasks" + if [[ "$updated_count" -lt "$stale_count" ]]; then + log_warn "$((stale_count - updated_count)) task(s) could not be reconciled" + fi + fi + + return 0 }