diff --git a/.agents/scripts/issue-sync-helper.sh b/.agents/scripts/issue-sync-helper.sh index 4d6f06715..30f7e0c4d 100755 --- a/.agents/scripts/issue-sync-helper.sh +++ b/.agents/scripts/issue-sync-helper.sh @@ -860,9 +860,9 @@ cmd_pull() { verify_gh_cli || return 1 - # Get all open issues with t-number prefixes + # Get all open issues with t-number prefixes (include assignees for assignee: sync) local issues_json - issues_json=$(gh issue list --repo "$repo_slug" --state open --limit 200 --json number,title 2>/dev/null || echo "[]") + issues_json=$(gh issue list --repo "$repo_slug" --state open --limit 200 --json number,title,assignees 2>/dev/null || echo "[]") local synced=0 while IFS= read -r issue_line; do @@ -933,7 +933,64 @@ cmd_pull() { synced=$((synced + 1)) done < <(echo "$closed_json" | jq -c '.[]' 2>/dev/null || true) - print_info "Pull complete: $synced refs synced to TODO.md" + # Sync GitHub Issue assignees → TODO.md assignee: field (t165 bi-directional sync) + local assignee_synced=0 + while IFS= read -r issue_line; do + local issue_number + issue_number=$(echo "$issue_line" | jq -r '.number' 2>/dev/null || echo "") + local issue_title + issue_title=$(echo "$issue_line" | jq -r '.title' 2>/dev/null || echo "") + local assignee_login + assignee_login=$(echo "$issue_line" | jq -r '.assignees[0].login // empty' 2>/dev/null || echo "") + + local task_id + task_id=$(echo "$issue_title" | grep -oE '^t[0-9]+(\.[0-9]+)*' || echo "") + if [[ -z "$task_id" || -z "$assignee_login" ]]; then + continue + fi + + # Check if task exists in TODO.md + if ! grep -qE "^- \[.\] ${task_id} " "$todo_file" 2>/dev/null; then + continue + fi + + # Check if TODO.md already has an assignee: on this task + local task_line_content + task_line_content=$(grep -E "^- \[.\] ${task_id} " "$todo_file" | head -1 || echo "") + local existing_assignee + existing_assignee=$(echo "$task_line_content" | grep -oE 'assignee:[A-Za-z0-9._@-]+' | head -1 | sed 's/^assignee://' || echo "") + + if [[ -n "$existing_assignee" ]]; then + # Already has an assignee — TODO.md is authoritative, don't overwrite + continue + fi + + # No assignee in TODO.md but issue has assignee — sync it + if [[ "$DRY_RUN" == "true" ]]; then + print_info "[DRY-RUN] Would add assignee:$assignee_login to $task_id (from GH#$issue_number)" + assignee_synced=$((assignee_synced + 1)) + continue + fi + + # Add assignee:login before logged: or at end of line + local line_num + line_num=$(grep -nE "^- \[.\] ${task_id} " "$todo_file" | head -1 | cut -d: -f1) + if [[ -n "$line_num" ]]; then + local current_line + current_line=$(sed -n "${line_num}p" "$todo_file") + local new_line + if echo "$current_line" | grep -qE 'logged:'; then + new_line=$(echo "$current_line" | sed -E "s/( logged:)/ assignee:${assignee_login}\1/") + else + new_line="${current_line} assignee:${assignee_login}" + fi + sed_inplace "${line_num}s|.*|${new_line}|" "$todo_file" + log_verbose "Synced assignee:$assignee_login to $task_id (from GH#$issue_number)" + assignee_synced=$((assignee_synced + 1)) + fi + done < <(echo "$issues_json" | jq -c '.[]' 2>/dev/null || true) + + print_info "Pull complete: $synced refs synced, $assignee_synced assignees synced to TODO.md" return 0 } diff --git a/.agents/scripts/pre-edit-check.sh b/.agents/scripts/pre-edit-check.sh index 195d44eed..b54849d37 100755 --- a/.agents/scripts/pre-edit-check.sh +++ b/.agents/scripts/pre-edit-check.sh @@ -203,35 +203,24 @@ source "${SCRIPT_DIR}/shared-constants.sh" echo "FEATURE_BRANCH_WARNING=$current_branch" exit 3 else - # Check if task is claimed by someone else on GitHub (t164) + # Check if task is claimed by someone else via TODO.md assignee: field (t165) # Note: no 'local' — this runs at script top-level, not inside a function task_id_from_branch="" task_id_from_branch=$(echo "$current_branch" | grep -oE 't[0-9]+' | head -1 || true) if [[ -n "$task_id_from_branch" ]]; then - supervisor_script="$SCRIPT_DIR/supervisor-helper.sh" - if [[ -x "$supervisor_script" ]]; then - project_root="" - project_root=$(git rev-parse --show-toplevel 2>/dev/null || echo ".") - todo_file="$project_root/TODO.md" - issue_number="" - if [[ -f "$todo_file" ]]; then - task_line="" - task_line=$(grep -E "^\- \[.\] ${task_id_from_branch} " "$todo_file" | head -1 || true) - issue_number=$(echo "$task_line" | grep -oE 'ref:GH#[0-9]+' | head -1 | sed 's/ref:GH#//' || true) - fi - if [[ -n "$issue_number" ]] && command -v gh &>/dev/null; then - repo_slug="" - repo_slug=$(git remote get-url origin 2>/dev/null | sed 's|.*github.com[:/]||;s|\.git$||' || true) - if [[ -n "$repo_slug" ]]; then - current_assignee="" - current_assignee=$(gh api "repos/$repo_slug/issues/$issue_number" --jq '.assignee.login // empty' 2>/dev/null || true) - if [[ -n "$current_assignee" ]]; then - my_login="" - my_login=$(gh api user --jq '.login' 2>/dev/null || true) - if [[ "$current_assignee" != "$my_login" ]]; then - echo -e "${YELLOW}WARNING${NC}: Task $task_id_from_branch is claimed by @$current_assignee (GH#$issue_number)" - fi - fi + project_root="" + project_root=$(git rev-parse --show-toplevel 2>/dev/null || echo ".") + todo_file="$project_root/TODO.md" + if [[ -f "$todo_file" ]]; then + task_line="" + task_line=$(grep -E "^\- \[.\] ${task_id_from_branch} " "$todo_file" | head -1 || true) + task_assignee="" + task_assignee=$(echo "$task_line" | grep -oE 'assignee:[A-Za-z0-9._@-]+' | head -1 | sed 's/^assignee://' || true) + if [[ -n "$task_assignee" ]]; then + # Must match get_aidevops_identity() in supervisor-helper.sh + my_identity="${AIDEVOPS_IDENTITY:-$(whoami 2>/dev/null || echo unknown)@$(hostname -s 2>/dev/null || echo local)}" + if [[ "$task_assignee" != "$my_identity" ]]; then + echo -e "${YELLOW}WARNING${NC}: Task $task_id_from_branch is claimed by assignee:$task_assignee" fi fi fi diff --git a/.agents/scripts/supervisor-helper.sh b/.agents/scripts/supervisor-helper.sh index 228d138f0..e39d69a05 100755 --- a/.agents/scripts/supervisor-helper.sh +++ b/.agents/scripts/supervisor-helper.sh @@ -1887,8 +1887,54 @@ find_task_issue_number() { } ####################################### -# Claim a task via GitHub Issue assignee (t164) -# Uses GitHub as distributed lock — works across machines +# Get the identity string for task claiming (t165) +# Uses AIDEVOPS_IDENTITY env var, falls back to user@hostname +####################################### +get_aidevops_identity() { + if [[ -n "${AIDEVOPS_IDENTITY:-}" ]]; then + echo "$AIDEVOPS_IDENTITY" + return 0 + fi + local user host + user=$(whoami 2>/dev/null || echo "unknown") + host=$(hostname -s 2>/dev/null || echo "local") + echo "${user}@${host}" + return 0 +} + +####################################### +# Get the assignee: value from a task line in TODO.md (t165) +# Outputs the assignee identity string, empty if unassigned. +# $1: task_id $2: todo_file path +####################################### +get_task_assignee() { + local task_id="$1" + local todo_file="$2" + + if [[ ! -f "$todo_file" ]]; then + return 0 + fi + + local task_id_escaped + task_id_escaped=$(printf '%s' "$task_id" | sed 's/\./\\./g') + + local task_line + task_line=$(grep -E "^- \[.\] ${task_id_escaped} " "$todo_file" | head -1 || echo "") + if [[ -z "$task_line" ]]; then + return 0 + fi + + # Extract assignee:value — unambiguous key:value field + local assignee + assignee=$(echo "$task_line" | grep -oE 'assignee:[A-Za-z0-9._@-]+' | head -1 | sed 's/^assignee://' || echo "") + echo "$assignee" + return 0 +} + +####################################### +# Claim a task (t165) +# Primary: TODO.md assignee: field (provider-agnostic, offline-capable) +# Optional: sync to GitHub Issue assignee if ref:GH# exists and gh is available ####################################### cmd_claim() { local task_id="${1:-}" @@ -1900,51 +1946,87 @@ cmd_claim() { local project_root project_root=$(find_project_root 2>/dev/null || echo ".") + local todo_file="$project_root/TODO.md" - local issue_number - issue_number=$(find_task_issue_number "$task_id" "$project_root") - if [[ -z "$issue_number" ]]; then - log_error "No GitHub issue found for $task_id (missing ref:GH# in TODO.md)" - return 1 - fi - - local repo_slug - repo_slug=$(detect_repo_slug "$project_root" 2>/dev/null || echo "") - if [[ -z "$repo_slug" ]]; then - log_error "Cannot detect repo slug" + if [[ ! -f "$todo_file" ]]; then + log_error "TODO.md not found at $todo_file" return 1 fi - # Ensure status labels exist before using them - ensure_status_labels "$repo_slug" + local identity + identity=$(get_aidevops_identity) - # Check current assignee + # Check current assignee in TODO.md local current_assignee - current_assignee=$(gh api "repos/$repo_slug/issues/$issue_number" --jq '.assignee.login // empty' 2>/dev/null || echo "") + current_assignee=$(get_task_assignee "$task_id" "$todo_file") if [[ -n "$current_assignee" ]]; then - local my_login - my_login=$(gh api user --jq '.login' 2>/dev/null || echo "") - if [[ "$current_assignee" == "$my_login" ]]; then - log_info "$task_id already claimed by you (GH#$issue_number)" + if [[ "$current_assignee" == "$identity" ]]; then + log_info "$task_id already claimed by you (assignee:$identity)" return 0 fi - log_error "$task_id is claimed by @$current_assignee (GH#$issue_number)" + log_error "$task_id is claimed by assignee:$current_assignee" return 1 fi - # Assign to self and update labels in a single call - if gh issue edit "$issue_number" --repo "$repo_slug" --add-assignee "@me" --add-label "status:claimed" --remove-label "status:available" 2>/dev/null; then - log_success "Claimed $task_id (GH#$issue_number assigned to you)" - return 0 - else - log_error "Failed to claim $task_id (GH#$issue_number)" + # Verify task exists and is open + local task_id_escaped + task_id_escaped=$(printf '%s' "$task_id" | sed 's/\./\\./g') + local task_line + task_line=$(grep -E "^- \[ \] ${task_id_escaped} " "$todo_file" | head -1 || echo "") + if [[ -z "$task_line" ]]; then + log_error "Task $task_id not found as open in $todo_file" + return 1 + fi + + # Add assignee:identity and started:ISO to the task line + local now + now=$(date -u +%Y-%m-%dT%H:%M:%SZ) + local line_num + line_num=$(grep -nE "^- \[ \] ${task_id_escaped} " "$todo_file" | head -1 | cut -d: -f1) + if [[ -z "$line_num" ]]; then + log_error "Could not find line number for $task_id" return 1 fi + + # Escape identity for safe sed interpolation (handles . / & \ in user@host) + local identity_esc + identity_esc=$(printf '%s' "$identity" | sed -e 's/[\/&.\\]/\\&/g') + + # Insert assignee: and started: before logged: or at end of metadata + local new_line + if echo "$task_line" | grep -qE 'logged:'; then + new_line=$(echo "$task_line" | sed -E "s/( logged:)/ assignee:${identity_esc} started:${now}\1/") + else + new_line="${task_line} assignee:${identity} started:${now}" + fi + sed_inplace "${line_num}s|.*|${new_line}|" "$todo_file" + + # Commit and push (optimistic lock — push failure = someone else claimed first) + if commit_and_push_todo "$project_root" "chore: claim $task_id by assignee:$identity"; then + log_success "Claimed $task_id (assignee:$identity, started:$now)" + else + # Push failed — check if someone else claimed + git -C "$project_root" checkout -- TODO.md 2>/dev/null || true + git -C "$project_root" pull --rebase 2>/dev/null || true + local new_assignee + new_assignee=$(get_task_assignee "$task_id" "$todo_file") + if [[ -n "$new_assignee" && "$new_assignee" != "$identity" ]]; then + log_error "$task_id was claimed by assignee:$new_assignee (race condition)" + return 1 + fi + log_warn "Claimed locally but push failed — will retry on next pulse" + fi + + # Optional: sync to GitHub Issue assignee (bi-directional sync layer) + sync_claim_to_github "$task_id" "$project_root" "claim" + return 0 } ####################################### -# Release a claimed task (t164) +# Release a claimed task (t165) +# Primary: TODO.md remove assignee: +# Optional: sync to GitHub Issue ####################################### cmd_unclaim() { local task_id="${1:-}" @@ -1956,85 +2038,131 @@ cmd_unclaim() { local project_root project_root=$(find_project_root 2>/dev/null || echo ".") + local todo_file="$project_root/TODO.md" - local issue_number - issue_number=$(find_task_issue_number "$task_id" "$project_root") - if [[ -z "$issue_number" ]]; then - log_error "No GitHub issue found for $task_id" + if [[ ! -f "$todo_file" ]]; then + log_error "TODO.md not found at $todo_file" return 1 fi - local repo_slug - repo_slug=$(detect_repo_slug "$project_root" 2>/dev/null || echo "") - if [[ -z "$repo_slug" ]]; then - log_error "Cannot detect repo slug" + local identity + identity=$(get_aidevops_identity) + + local current_assignee + current_assignee=$(get_task_assignee "$task_id" "$todo_file") + + if [[ -z "$current_assignee" ]]; then + log_info "$task_id is not claimed" + return 0 + fi + + if [[ "$current_assignee" != "$identity" ]]; then + log_error "$task_id is claimed by assignee:$current_assignee, not by you (assignee:$identity)" return 1 fi - local my_login - my_login=$(gh api user --jq '.login' 2>/dev/null || echo "") - if [[ -z "$my_login" ]]; then - log_error "Cannot determine GitHub login for unclaim" + # Remove assignee:identity and started:... from the task line + local task_id_escaped + task_id_escaped=$(printf '%s' "$task_id" | sed 's/\./\\./g') + local line_num + line_num=$(grep -nE "^- \[.\] ${task_id_escaped} " "$todo_file" | head -1 | cut -d: -f1) + if [[ -z "$line_num" ]]; then + log_error "Could not find line number for $task_id" return 1 fi - # Ensure status labels exist before using them - ensure_status_labels "$repo_slug" + local task_line + task_line=$(sed -n "${line_num}p" "$todo_file") + local new_line + # Remove assignee:value and started:value + # Use character class pattern (no identity interpolation needed — matches any assignee) + new_line=$(echo "$task_line" | sed -E "s/ ?assignee:[A-Za-z0-9._@-]+//; s/ ?started:[0-9T:Z-]+//") + sed_inplace "${line_num}s|.*|${new_line}|" "$todo_file" - # Remove assignee and update labels in a single call - if gh issue edit "$issue_number" --repo "$repo_slug" --remove-assignee "$my_login" --add-label "status:available" --remove-label "status:claimed" 2>/dev/null; then - log_success "Released $task_id (GH#$issue_number unassigned)" - return 0 + if commit_and_push_todo "$project_root" "chore: unclaim $task_id (released by assignee:$identity)"; then + log_success "Released $task_id (unclaimed by assignee:$identity)" else - log_error "Failed to release $task_id" - return 1 + log_warn "Unclaimed locally but push failed — will retry on next pulse" fi + + # Optional: sync to GitHub Issue + sync_claim_to_github "$task_id" "$project_root" "unclaim" + return 0 } ####################################### -# Check if a task is claimed by someone else (t164) -# Returns 0 if free or claimed by self, 1 if claimed by another +# Check if a task is claimed by someone else (t165) +# Primary: TODO.md assignee: field (instant, offline) +# Returns 0 if free or claimed by self, 1 if claimed by another. +# Outputs the assignee on stdout if claimed by another. ####################################### check_task_claimed() { local task_id="${1:-}" local project_root="${2:-.}" + local todo_file="$project_root/TODO.md" - local issue_number - issue_number=$(find_task_issue_number "$task_id" "$project_root") + local current_assignee + current_assignee=$(get_task_assignee "$task_id" "$todo_file") - # No issue = no claim mechanism = free - if [[ -z "$issue_number" ]]; then + # No assignee = free + if [[ -z "$current_assignee" ]]; then return 0 fi - local repo_slug - repo_slug=$(detect_repo_slug "$project_root" 2>/dev/null || echo "") - if [[ -z "$repo_slug" ]]; then + local identity + identity=$(get_aidevops_identity) + + # Claimed by self = OK + if [[ "$current_assignee" == "$identity" ]]; then return 0 fi - local current_assignee api_exit=0 - current_assignee=$(gh api "repos/$repo_slug/issues/$issue_number" --jq '.assignee.login // empty' 2>/dev/null) || api_exit=$? + # Claimed by someone else + echo "$current_assignee" + return 1 +} + +####################################### +# Sync claim/unclaim to GitHub Issue assignee (t165) +# Optional bi-directional sync layer — fails silently if gh unavailable. +# $1: task_id $2: project_root $3: action (claim|unclaim) +####################################### +sync_claim_to_github() { + local task_id="$1" + local project_root="$2" + local action="$3" + + # Skip if gh CLI not available + command -v gh &>/dev/null || return 0 - if [[ "$api_exit" -ne 0 ]]; then - # API failed (network, rate limit, etc.) — fail open to avoid blocking dispatch - log_info "Could not check claim status for $task_id (gh api exit $api_exit) — proceeding" + local issue_number + issue_number=$(find_task_issue_number "$task_id" "$project_root") + if [[ -z "$issue_number" ]]; then return 0 fi - if [[ -z "$current_assignee" ]]; then + local repo_slug + repo_slug=$(detect_repo_slug "$project_root" 2>/dev/null || echo "") + if [[ -z "$repo_slug" ]]; then return 0 fi - local my_login - my_login=$(gh api user --jq '.login' 2>/dev/null || echo "") + ensure_status_labels "$repo_slug" - if [[ -n "$my_login" && "$current_assignee" == "$my_login" ]]; then - return 0 + if [[ "$action" == "claim" ]]; then + gh issue edit "$issue_number" --repo "$repo_slug" \ + --add-assignee "@me" \ + --add-label "status:claimed" --remove-label "status:available" 2>/dev/null || true + elif [[ "$action" == "unclaim" ]]; then + local my_login + my_login=$(gh api user --jq '.login' 2>/dev/null || echo "") + if [[ -n "$my_login" ]]; then + gh issue edit "$issue_number" --repo "$repo_slug" \ + --remove-assignee "$my_login" \ + --add-label "status:available" --remove-label "status:claimed" 2>/dev/null || true + fi fi - - echo "$current_assignee" - return 1 + return 0 } ####################################### @@ -2687,17 +2815,20 @@ cmd_dispatch() { return 1 fi - # Check if task is claimed by someone else on GitHub (t164) - # Note: return 0 to let pulse continue dispatching other tasks (return 2 = concurrency limit) + # Check if task is claimed by someone else via TODO.md assignee: field (t165) local claimed_by="" claimed_by=$(check_task_claimed "$task_id" "${trepo:-.}" 2>/dev/null) || true if [[ -n "$claimed_by" ]]; then - log_warn "Task $task_id is claimed by @$claimed_by on GitHub — skipping dispatch" + log_warn "Task $task_id is claimed by assignee:$claimed_by — skipping dispatch" return 0 fi - # Claim the task on GitHub before dispatching (t164) - cmd_claim "$task_id" 2>/dev/null || log_info "Could not claim $task_id on GitHub (no issue ref or gh unavailable)" + # Claim the task before dispatching (t165 — TODO.md primary, GH Issue sync optional) + # CRITICAL: abort dispatch if claim fails (race condition = another worker claimed first) + if ! cmd_claim "$task_id"; then + log_error "Failed to claim $task_id — aborting dispatch" + return 1 + fi # Check concurrency limit with adaptive load awareness (t151) if [[ -n "$batch_id" ]]; then diff --git a/.agents/workflows/plans.md b/.agents/workflows/plans.md index 88d7509c1..224e312c9 100644 --- a/.agents/workflows/plans.md +++ b/.agents/workflows/plans.md @@ -686,31 +686,40 @@ An "always switch branches for TODO.md" rule fails the 80% universal applicabili **Bottom line**: Use judgment. Related work stays together; unrelated TODO-only backlog goes directly to main; mixed changes use a worktree. -## Distributed Task Claiming (t164) +## Distributed Task Claiming (t164/t165) -When multiple machines or agents work on the same repo, **GitHub Issue assignees** are the single source of truth for task ownership. The supervisor DB is a local cache only. +**TODO.md is the master source of truth** for task ownership. Git platform issues (GitHub, GitLab) are a public interface for external contributors — they are bi-directionally synced but never authoritative over TODO.md. **Claim flow:** ```bash -supervisor-helper.sh claim tNNN # Assign GH issue to yourself -supervisor-helper.sh unclaim tNNN # Release the claim +supervisor-helper.sh claim tNNN # Add assignee:identity + started:ISO to task line, sync to GH issue +supervisor-helper.sh unclaim tNNN # Remove assignee: + started:, sync to GH issue ``` **How it works:** +| Step | What happens | +|------|-------------| +| **Claim** | `git pull` → check `assignee:` in TODO.md → add `assignee:identity started:ISO` → `commit + push` → sync to GH issue | +| **Check** | `grep "assignee:"` on task line — instant, offline | +| **Unclaim** | Remove `assignee:` + `started:` → `commit + push` → sync to GH issue | +| **Race protection** | Git push rejection = someone else claimed first. Pull, re-check, abort. | + +**Identity:** Set `AIDEVOPS_IDENTITY` env var, or defaults to `$(whoami)@$(hostname -s)`. + +**Who claims:** + | Actor | Before work | During work | After work | |-------|-------------|-------------|------------| -| **Supervisor** | `claim` before dispatch (auto) | Worker runs | Manual `unclaim` or PR merge closes issue | -| **Human** | `claim` or assign on GitHub | Edit code | PR merge closes issue | +| **Supervisor** | `claim` before dispatch (auto) | Worker runs | Manual `unclaim` or task completion | +| **Human** | `claim` or add `assignee:name` manually | Edit code | PR merge, mark `[x]` | | **Pre-edit check** | Warns if claimed by another | — | — | -> **Note:** Auto-unclaim on task completion is planned for a future iteration. Currently, claims are released manually or when the GitHub Issue is closed. +**Bi-directional sync:** When `gh` CLI is available and the task has `ref:GH#`, claiming/unclaiming automatically syncs to GitHub Issue assignees and status labels. If someone assigns themselves on GitHub, `issue-sync pull` brings that back as `assignee:` in TODO.md. The sync is optional — claiming works fully offline with any git remote. **Status labels** on GitHub Issues: `status:available` → `status:claimed` → `status:in-review` → `status:done` -**Why GitHub, not local DB?** The supervisor DB is per-machine (SQLite). GitHub Issues are visible to all machines, all agents, and all humans. No sync needed. - ## MANDATORY: Worker TODO.md Restriction **Workers (headless dispatch runners) must NEVER edit TODO.md directly.** This is the primary cause of merge conflicts when multiple workers + supervisor all push to TODO.md on main simultaneously. diff --git a/TODO.md b/TODO.md index 783cc63b6..b46a3ccd4 100644 --- a/TODO.md +++ b/TODO.md @@ -38,6 +38,11 @@ Compatible with [todo-md](https://github.com/todo-md/todo-md), [todomd](https:// - `started:` - When branch was created (work began) - `completed:` - When task was marked done +**Assignment fields:** + +- `assignee:identity` - Who claimed the task (set by `supervisor-helper.sh claim`, identity = `AIDEVOPS_IDENTITY` or `user@host`) +- `@owner` - Who is responsible for the task (separate from assignee — future feature) + **Machine data:** TOON blocks in HTML comments (invisible when rendered).