Compare commits

..

22 Commits

Author SHA1 Message Date
shokollm
591dcb4285 fix(queue-daemon): make base branch configurable via KUGETSU_BASE_BRANCH
- Add KUGETSU_BASE_BRANCH env var (default: origin/main)
- Update check_task_completion() to use configurable base branch
- Update create_worktree() to use configurable base branch

Closes #165
2026-04-08 06:13:08 +00:00
ab99647193 Merge pull request 'fix(shell): address shellcheck warnings, standardize error handling, add retry logic' (#253) from fix/issue-121 into main 2026-04-08 07:04:26 +02:00
shokollm
3deae2dd81 fix(shell): address shellcheck warnings, standardize error handling, add retry logic
- Fix shellcheck SC2155 (separate variable assignment from declaration)
- Replace ! bool && bool pattern with [ "$bool" = true ] && [ "$bool2" = true ]
- Add retry logic for git clone with configurable attempts
- Add retry logic for opencode session fork
- Add NETWORK_RETRY_ATTEMPTS and NETWORK_RETRY_DELAY_SECONDS config vars
- Add retry_with_backoff helper function in kugetsu-config.sh
- Replace subshell cd with git -C for safer directory handling
2026-04-08 04:47:01 +00:00
41c56a859c Merge pull request 'refactor: use JSON file exchange instead of stdout parsing' (#234) from fix/issue-119 into main 2026-04-08 05:35:11 +02:00
shokollm
dd903bb8aa refactor: use JSON file exchange instead of stdout parsing
Replace fragile patterns like:
  python3 -c "import json; print(json.load(...))" | grep
  echo "$json" | python3 -c "import sys,json; ...
2026-04-08 03:11:18 +00:00
efb1e34a7b Merge pull request 'fix: add PR merge conflict check to dev agent workflow' (#233) from fix/issue-229-pr-conflict-check into main 2026-04-08 04:56:02 +02:00
44c84280f8 Merge pull request 'fix(queue-daemon): implement timeout handling for long-running tasks' (#228) from fix/issue-166 into main 2026-04-08 04:51:54 +02:00
shokollm
fa8b8467ee fix(queue-daemon): use kugetsu-log for timeout messages
Use log_warn and log_error instead of echo for unified log formatting.
2026-04-08 02:39:40 +00:00
shokollm
c9bdc0dd88 refactor: unify duplicated prompt sections using conditional variables
- Extract conflict_check and delegator_section as conditional variables
- Single heredoc instead of duplicate blocks
- Resolve code duplication raised in PR comment
2026-04-08 02:35:30 +00:00
shokollm
663e44a82a fix: add PR merge conflict check and review reading to dev agent workflow
Dev agent should:
1. Check if PR has merge conflicts before asking for review
2. Read review comments and incorporate feedback
3. Understand review states: APPROVED = ready to merge, COMMENT = feedback to address

This prevents approving a PR that has conflicts, and ensures dev agents
respond to reviewer comments appropriately.
2026-04-08 02:27:42 +00:00
a65e9d6d28 Merge pull request 'feat(session): integrate kugetsu_context_dump into delegation flow' (#229) from fix/issue-212 into main 2026-04-08 04:22:00 +02:00
shokollm
c9eb8badea feat(session): add kugetsu_context_dump call in cmd_continue
Integrates the existing kugetsu_context_dump() function to capture
the initial user prompt before forking the agent.

Closes #212
2026-04-08 02:17:27 +00:00
shokollm
24dd91d0e1 fix: remove duplicate fi in cmd_continue 2026-04-08 01:04:43 +00:00
c8b2ab6b12 Merge pull request 'fix: always use base workflow with user message' (#232) from fix/issue-229-user-message-with-base-workflow into main 2026-04-08 02:33:22 +02:00
shokollm
87434d1bca fix: always use base workflow and add user message to it
Previously when a user message was provided, cmd_continue would only
use the user message without the base agent workflow. Now both cases
build the full workflow and append the user message.

Changes:
- cmd_continue now always calls build_dev_agent_message even when
  message is provided
- User message is appended at the end with 'Delegator's message:'
- Both with/without message cases now use the same workflow structure

Fixes #229
2026-04-08 00:11:03 +00:00
ae8f1433a7 Merge pull request 'fix(session): remove incorrect worktree removal in ensure_session' (#231) from fix/issue-229-ensure-session-worktree-bug into main 2026-04-08 01:52:08 +02:00
shokollm
b16a97514e fix(session): remove incorrect worktree removal in ensure_session
When worktree exists but session is missing, ensure_session was
incorrectly removing the worktree before recreating. This caused
issues when cmd_continue called ensure_worktree first (creating the
worktree) then ensure_session (which wrongly removed it).

The fix removes the block that removes worktree when session is missing.
If worktree exists, just create the session without touching the worktree.

Fixes #229
2026-04-07 23:39:18 +00:00
b09fe8eabc Merge pull request 'refactor(session): make cmd_continue idempotent with ensure_* functions' (#230) from fix/issue-168 into main 2026-04-08 00:32:16 +02:00
shokollm
d240000088 refactor(session): make cmd_continue idempotent with ensure_* functions
- Add ensure_worktree() - creates worktree if missing, returns status
- Add ensure_session() - creates session if missing, handles inconsistent states
- Add fork_agent() - extracted agent forking logic
- Refactor cmd_continue() to use ensure_* functions (idempotent)
- Make cmd_start() a thin wrapper calling cmd_continue()
- Simplify daemon to always call cmd_continue (no existence check)

This makes cmd_continue truly idempotent - it will:
- Continue existing session if it exists
- Create session and worktree if they don't exist
- Clean and recreate if state is inconsistent

Closes #168
2026-04-07 22:25:53 +00:00
05683ea4c6 Merge pull request 'fix: accumulate message words in cmd_continue for loop' (#227) from fix/issue-225-cmd-continue-message-truncation into main 2026-04-07 15:57:40 +02:00
shokollm
2800e140ac fix: accumulate message words instead of overwriting in cmd_continue
The for loop was overwriting message each iteration, causing only
the last word to survive. Changed to accumulate all words with
proper spacing.

Fixes #225
2026-04-07 13:55:47 +00:00
shokollm
51ec844365 fix(queue-daemon): implement timeout handling for long-running tasks
Check notified_at timestamp in check_task_completion() and mark tasks
as error if they exceed TASK_TIMEOUT_HOURS (defaults to 1 hour).

When timeout is detected:
- Kill the task process (PID) if running
- Stop the opencode session if exists
- Mark queue item as error state

Fixes #166
2026-04-07 12:53:35 +00:00
6 changed files with 363 additions and 174 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ results/
*/results/ */results/
*.pyc *.pyc
.kugetsu/

View File

@@ -26,6 +26,10 @@ QUEUE_DAEMON_INTERVAL_MINUTES="${QUEUE_DAEMON_INTERVAL_MINUTES:-5}"
QUEUE_CLEANUP_AGE_DAYS="${QUEUE_CLEANUP_AGE_DAYS:-7}" QUEUE_CLEANUP_AGE_DAYS="${QUEUE_CLEANUP_AGE_DAYS:-7}"
TASK_TIMEOUT_HOURS="${TASK_TIMEOUT_HOURS:-1}" TASK_TIMEOUT_HOURS="${TASK_TIMEOUT_HOURS:-1}"
NETWORK_RETRY_ATTEMPTS="${NETWORK_RETRY_ATTEMPTS:-3}"
NETWORK_RETRY_DELAY_SECONDS="${NETWORK_RETRY_DELAY_SECONDS:-5}"
KUGETSU_BASE_BRANCH="${KUGETSU_BASE_BRANCH:-origin/main}"
# Load user config overrides (~/.kugetsu/config) # Load user config overrides (~/.kugetsu/config)
if [ -f "$KUGETSU_DIR/config" ]; then if [ -f "$KUGETSU_DIR/config" ]; then
source "$KUGETSU_DIR/config" source "$KUGETSU_DIR/config"
@@ -87,3 +91,24 @@ set_debug_mode() {
echo "${filtered_args[@]}" echo "${filtered_args[@]}"
} }
retry_with_backoff() {
local max_attempts="${1:-$NETWORK_RETRY_ATTEMPTS}"
local delay_seconds="${2:-$NETWORK_RETRY_DELAY_SECONDS}"
local command="$3"
local remaining_attempts=$max_attempts
while [ $remaining_attempts -gt 0 ]; do
if eval "$command"; then
return 0
fi
remaining_attempts=$((remaining_attempts - 1))
if [ $remaining_attempts -gt 0 ]; then
log "warn" "retry_with_backoff" "Command failed, $remaining_attempts retries remaining. Waiting ${delay_seconds}s..."
sleep "$delay_seconds"
delay_seconds=$((delay_seconds * 2))
fi
done
log "error" "retry_with_backoff" "Command failed after $max_attempts attempts"
return 1
}

View File

@@ -139,6 +139,77 @@ validate_issue_ref() {
fi fi
} }
read_json_file() {
local file_path="$1"
if [ -f "$file_path" ]; then
cat "$file_path"
else
echo "{}"
fi
}
write_json_file() {
local file_path="$1"
local json_content="$2"
local temp_file="$file_path.tmp.$$"
printf '%s' "$json_content" > "$temp_file"
if ! python3 -c "import json; json.load(open('$temp_file'))" 2>/dev/null; then
echo "Error: write_json_file would create malformed JSON: $file_path" >&2
rm -f "$temp_file"
return 1
fi
mv "$temp_file" "$file_path"
}
get_json_value() {
local file_path="$1"
local key="$2"
local default="${3:-}"
if [ ! -f "$file_path" ]; then
echo "$default"
return
fi
python3 -c "import json; print(json.load(open('$file_path')).get('$key', '$default'))" 2>/dev/null || echo "$default"
}
set_json_value() {
local file_path="$1"
local key="$2"
local value="$3"
if [ ! -f "$file_path" ]; then
printf '{"%s": "%s"}\n' "$key" "$value" > "$file_path"
return
fi
python3 << PYEOF
import json
import sys
file_path = "$file_path"
key = "$key"
value = "$value"
try:
with open(file_path, 'r') as f:
data = json.load(f)
except:
data = {}
data[key] = value
with open(file_path, 'w') as f:
json.dump(data, f, indent=2)
print(f"Set $key = $value in $file_path")
PYEOF
}
update_session_pr_url() { update_session_pr_url() {
local issue_ref="$1" local issue_ref="$1"
local pr_url="$2" local pr_url="$2"

View File

@@ -34,6 +34,8 @@ release_lock() {
check_task_completion() { check_task_completion() {
local item="$1" local item="$1"
local queue_id=$(basename "$item" .json) local queue_id=$(basename "$item" .json)
local item_data=$(read_json_file "$item")
local state=$(python3 -c "import json; print(json.load(open('$item')).get('state', ''))" 2>/dev/null) local state=$(python3 -c "import json; print(json.load(open('$item')).get('state', ''))" 2>/dev/null)
[ "$state" = "notified" ] || return 0 [ "$state" = "notified" ] || return 0
@@ -41,6 +43,31 @@ check_task_completion() {
local session_id=$(python3 -c "import json; print(json.load(open('$item')).get('opencode_session_id', ''))" 2>/dev/null) local session_id=$(python3 -c "import json; print(json.load(open('$item')).get('opencode_session_id', ''))" 2>/dev/null)
local issue_ref=$(python3 -c "import json; print(json.load(open('$item')).get('issue_ref', ''))" 2>/dev/null) local issue_ref=$(python3 -c "import json; print(json.load(open('$item')).get('issue_ref', ''))" 2>/dev/null)
local pid=$(python3 -c "import json; print(json.load(open('$item')).get('pid', ''))" 2>/dev/null) local pid=$(python3 -c "import json; print(json.load(open('$item')).get('pid', ''))" 2>/dev/null)
local notified_at=$(python3 -c "import json; print(json.load(open('$item')).get('notified_at', ''))" 2>/dev/null)
local timed_out=false
if [ -n "$notified_at" ]; then
local notified_epoch=$(date -d "$notified_at" +%s 2>/dev/null || echo "0")
local now_epoch=$(date +%s)
local hours_elapsed=$(( (now_epoch - notified_epoch) / 3600 ))
if [ "$hours_elapsed" -ge "${TASK_TIMEOUT_HOURS:-1}" ]; then
timed_out=true
log_warn "queue-daemon" "Task $queue_id ($issue_ref) timed out after ${hours_elapsed}h"
fi
fi
if [ "$timed_out" = true ]; then
if [ -n "$pid" ] && [ "$pid" != "None" ]; then
kill "$pid" 2>/dev/null || true
fi
if [ -n "$session_id" ]; then
opencode session stop "$session_id" 2>/dev/null || true
fi
update_queue_item_state "$queue_id" "error"
log_error "queue-daemon" "Task $queue_id ($issue_ref) marked error — timeout after ${hours_elapsed}h"
release_lock "$issue_ref"
return
fi
if [ -n "$pid" ] && [ "$pid" != "None" ]; then if [ -n "$pid" ] && [ "$pid" != "None" ]; then
if ! kill -0 "$pid" 2>/dev/null; then if ! kill -0 "$pid" 2>/dev/null; then
@@ -48,7 +75,7 @@ check_task_completion() {
local has_commits=false local has_commits=false
if [ -d "$worktree_path" ] && [ -d "$worktree_path/.git" ]; then if [ -d "$worktree_path" ] && [ -d "$worktree_path/.git" ]; then
if [ -n "$(git -C "$worktree_path" log --oneline origin/main..HEAD 2>/dev/null)" ]; then if [ -n "$(git -C "$worktree_path" log --oneline "$KUGETSU_BASE_BRANCH..HEAD" 2>/dev/null)" ]; then
has_commits=true has_commits=true
fi fi
fi fi
@@ -68,7 +95,7 @@ check_task_completion() {
local has_commits=false local has_commits=false
if [ -d "$worktree_path" ] && [ -d "$worktree_path/.git" ]; then if [ -d "$worktree_path" ] && [ -d "$worktree_path/.git" ]; then
if [ -n "$(git -C "$worktree_path" log --oneline origin/main..HEAD 2>/dev/null)" ]; then if [ -n "$(git -C "$worktree_path" log --oneline "$KUGETSU_BASE_BRANCH..HEAD" 2>/dev/null)" ]; then
has_commits=true has_commits=true
fi fi
fi fi
@@ -109,28 +136,15 @@ process_task() {
source "$SCRIPT_DIR/kugetsu-session.sh" source "$SCRIPT_DIR/kugetsu-session.sh"
if worktree_exists "$issue_ref" "$WORKTREES_DIR" || [ -f "$SESSIONS_DIR/$(issue_ref_to_filename "$issue_ref").json" ]; then log_file="$LOGS_DIR/delegate-$(date +%s).log"
log_file="$LOGS_DIR/delegate-$(date +%s).log" if cmd_continue "$issue_ref" "$message" >> "$log_file" 2>&1; then
if cmd_continue "$issue_ref" "$message" >> "$log_file" 2>&1; then sleep 1
sleep 1 local session_id=$(get_session_id_for_issue "$issue_ref")
local session_id=$(get_session_id_for_issue "$issue_ref") update_queue_item_state "$queue_id" "notified" "$session_id" ""
update_queue_item_state "$queue_id" "notified" "$session_id" "" echo "Task $queue_id continued for $issue_ref"
echo "Task $queue_id continued for $issue_ref"
else
update_queue_item_state "$queue_id" "error"
echo "Task $queue_id ($issue_ref) failed to continue"
fi
else else
log_file="$LOGS_DIR/delegate-$(date +%s).log" update_queue_item_state "$queue_id" "error"
if cmd_start "$issue_ref" "$message" >> "$log_file" 2>&1; then echo "Task $queue_id ($issue_ref) failed to continue"
sleep 1
local session_id=$(get_session_id_for_issue "$issue_ref")
update_queue_item_state "$queue_id" "notified" "$session_id" ""
echo "Task $queue_id started for $issue_ref"
else
update_queue_item_state "$queue_id" "error"
echo "Task $queue_id ($issue_ref) failed to start"
fi
fi fi
release_lock "$issue_ref" release_lock "$issue_ref"

View File

@@ -13,7 +13,8 @@ count_active_dev_sessions() {
if [ -d "$SESSIONS_DIR" ]; then if [ -d "$SESSIONS_DIR" ]; then
for session_file in "$SESSIONS_DIR"/*.json; do for session_file in "$SESSIONS_DIR"/*.json; do
if [ -f "$session_file" ]; then if [ -f "$session_file" ]; then
local filename=$(basename "$session_file") local filename
filename=$(basename "$session_file")
if [ "$filename" != "base.json" ] && [ "$filename" != "pm-agent.json" ]; then if [ "$filename" != "base.json" ] && [ "$filename" != "pm-agent.json" ]; then
count=$((count + 1)) count=$((count + 1))
fi fi
@@ -239,23 +240,55 @@ create_session() {
return 1 return 1
fi fi
local before_json=$(opencode session list --format=json 2>/dev/null) local before_file
local before_set=$(echo "$before_json" | python3 -c "import sys,json; sessions=json.load(sys.stdin); print('|'.join(s['id'] for s in sessions))" 2>/dev/null || echo "|") before_file="$KUGETSU_DIR/sessions/before$$.json"
local after_file
after_file="$KUGETSU_DIR/sessions/after$$.json"
opencode run --fork --session "$base_session" "new session" >/dev/null 2>&1 opencode session list --format=json > "$before_file" 2>/dev/null || printf '{}' > "$before_file"
local fork_success=false
local attempt=0
local max_attempts="${NETWORK_RETRY_ATTEMPTS:-3}"
while [ $attempt -lt $max_attempts ] && [ "$fork_success" = false ]; do
attempt=$((attempt + 1))
if opencode run --fork --session "$base_session" "new session" >/dev/null 2>&1; then
fork_success=true
elif [ $attempt -lt $max_attempts ]; then
log "warn" "create_session" "Fork attempt $attempt failed, retrying..."
sleep "$((attempt * 2))"
fi
done
if [ "$fork_success" = false ]; then
log "error" "create_session" "Failed to fork session after $max_attempts attempts"
rm -f "$before_file" "$after_file"
return 1
fi
sleep 1 sleep 1
local after_json=$(opencode session list --format=json 2>/dev/null) opencode session list --format=json > "$after_file" 2>/dev/null || printf '{}' > "$after_file"
local after_sessions=$(echo "$after_json" | python3 -c "import sys,json; sessions=json.load(sys.stdin); [print(s['id']) for s in sessions]" 2>/dev/null || true)
local new_session_id="" local new_session_id
while IFS= read -r sess; do new_session_id=$(python3 << PYEOF
if [[ -n "$sess" ]] && [[ ! "$before_set" =~ \|${sess}\| ]]; then import json
new_session_id="$sess"
break with open("$before_file", 'r') as f:
fi before = json.load(f)
done <<< "$after_sessions" with open("$after_file", 'r') as f:
after = json.load(f)
before_ids = set(s['id'] for s in before)
for s in after:
if s['id'] not in before_ids:
print(s['id'])
break
PYEOF
)
rm -f "$before_file" "$after_file"
echo "$new_session_id" echo "$new_session_id"
} }
@@ -270,40 +303,37 @@ build_dev_agent_message() {
local number=$(echo "$issue_ref" | grep -oE '#[0-9]+$' | tr -d '#') local number=$(echo "$issue_ref" | grep -oE '#[0-9]+$' | tr -d '#')
local worktree_path=$(issue_ref_to_worktree_path "$issue_ref") local worktree_path=$(issue_ref_to_worktree_path "$issue_ref")
local conflict_check=""
local review_notes=""
local delegator_header=""
local delegator_footer=""
if [ -n "$user_message" ]; then if [ -n "$user_message" ]; then
cat <<EOF conflict_check=" - CRITICAL: Check if PR has merge conflicts before asking for review:
You are continuing work on $issue_ref. A PR likely already exists. - Use: curl -s \"https://$instance/api/v1/repos/$owner/$repo/pulls/$number\" -H \"Authorization: Bearer \$GITEA_TOKEN\"
- If \"mergeable\": false, there ARE conflicts - you MUST resolve them FIRST
- To resolve: cd to worktree, git fetch origin, git rebase origin/main, resolve conflicts, git rebase --continue, git push --force-with-lease
- Only after resolving conflicts (mergeable: true) can you ask for review"
delegator_header="IMPORTANT: Follow the workflow below as your guideline, but prioritize the delegator's message.
IMPORTANT - Review workflow: Workflow:"
1. First, check if PR exists: curl -s "https://$instance/api/v1/repos/$owner/$repo/pulls?state=open" -H "Authorization: Bearer \$GITEA_TOKEN" | grep -i "$number" delegator_footer="
2. Get PR comments: curl -s "https://$instance/api/v1/repos/$owner/$repo/issues/$number/comments" -H "Authorization: Bearer \$GITEA_TOKEN"
3. Get PR reviews: curl -s "https://$instance/api/v1/repos/$owner/$repo/pulls/$number/reviews" -H "Authorization: Bearer \$GITEA_TOKEN"
You may need to:
- Make code changes and push to the same branch
- Reply to PR comments using: curl -X POST "https://$instance/api/v1/repos/$owner/$repo/issues/$number/comments" -H "Authorization: Bearer \$GITEA_TOKEN" -H "Content-Type: application/json" -d '{"body":"Your reply here"}'
- Or do both
MERGING: If instructed to merge, you MUST confirm approval first before merging:
- Check for PR approval via: curl -s "https://$instance/api/v1/repos/$owner/$repo/pulls/$number/reviews" -H "Authorization: Bearer \$GITEA_TOKEN"
- Check for "lgtm" or "approved" in comments: curl -s "https://$instance/api/v1/repos/$owner/$repo/issues/$number/comments" -H "Authorization: Bearer \$GITEA_TOKEN"
- Only merge if you see approval OR the instruction explicitly says to merge (e.g., "merge the PR", "please merge", "go ahead and merge")
- To merge: tea pr merge --repo $owner/$repo $number --style merge
- If no approval yet, reply asking for review/approval first
Delegator's message: Delegator's message:
$user_message $user_message"
Work directory: $worktree_path (already on the fix branch)
EOF
else else
cat <<EOF review_notes=" - IMPORTANT: After listing reviews, READ the review comments and incorporate feedback
- Check for review state: \"APPROVED\" means ready to merge, \"COMMENT\" means feedback to address"
delegator_header="Workflow:"
fi
cat <<EOF
You are assigned to work on $issue_ref. You are assigned to work on $issue_ref.
Workflow: $delegator_header
1. Read the issue at $instance/$owner/$repo/issues/$number AND all comments on that issue 1. Read the issue at $instance/$owner/$repo/issues/$number AND all comments on that issue
2. Check if a PR already exists for this issue 2. Check if a PR already exists for this issue
- If PR exists and is open, review it and learn from it - If PR exists and is open, review it and learn from it
$conflict_check
- If PR makes sense to continue, work on it instead - If PR makes sense to continue, work on it instead
- If PR is not worth continuing, create a new branch/PR but explain in PR description why you're creating a new one instead of continuing the existing PR - If PR is not worth continuing, create a new branch/PR but explain in PR description why you're creating a new one instead of continuing the existing PR
3. Read README.md (if exists) to understand the general concept of this repository 3. Read README.md (if exists) to understand the general concept of this repository
@@ -321,38 +351,58 @@ Tools for PR interaction:
- Post issue/PR comment: curl -X POST "https://$instance/api/v1/repos/$owner/$repo/issues/$number/comments" -H "Authorization: Bearer \$GITEA_TOKEN" -H "Content-Type: application/json" -d '{"body":"Your comment"}' - Post issue/PR comment: curl -X POST "https://$instance/api/v1/repos/$owner/$repo/issues/$number/comments" -H "Authorization: Bearer \$GITEA_TOKEN" -H "Content-Type: application/json" -d '{"body":"Your comment"}'
- List PR comments: curl -s "https://$instance/api/v1/repos/$owner/$repo/issues/$number/comments" -H "Authorization: Bearer \$GITEA_TOKEN" - List PR comments: curl -s "https://$instance/api/v1/repos/$owner/$repo/issues/$number/comments" -H "Authorization: Bearer \$GITEA_TOKEN"
- List PR reviews: curl -s "https://$instance/api/v1/repos/$owner/$repo/pulls/$number/reviews" -H "Authorization: Bearer \$GITEA_TOKEN" - List PR reviews: curl -s "https://$instance/api/v1/repos/$owner/$repo/pulls/$number/reviews" -H "Authorization: Bearer \$GITEA_TOKEN"
$review_notes
- Merge PR (only with approval): tea pr merge --repo $owner/$repo $number --style merge - Merge PR (only with approval): tea pr merge --repo $owner/$repo $number --style merge
- MERGING requires approval first! Check for: approval in reviews, OR "lgtm"/"approved" in comments - MERGING requires approval first! Check for: approval in reviews, OR "lgtm"/"approved" in comments
- If no approval, ask reviewer to approve first before merging - If no approval, ask reviewer to approve first before merging
$delegator_footer
Work directory: $worktree_path Work directory: $worktree_path
EOF EOF
fi
} }
cmd_start() { ensure_worktree() {
local issue_ref="${1:-}" local issue_ref="$1"
local message="${2:-}"
if [ -z "$issue_ref" ]; then if worktree_exists "$issue_ref" "$WORKTREES_DIR"; then
echo "Error: issue ref is required" >&2 log "info" "ensure_worktree" "Worktree already exists for $issue_ref"
echo "Usage: kugetsu start <issue-ref> [message]" >&2 echo "existed"
exit 1 return 0
fi fi
validate_issue_ref "$issue_ref"
local base_session_id=$(get_base_session_id) local base_session_id=$(get_base_session_id)
if [ -z "$base_session_id" ] || [ "$base_session_id" = "null" ]; then if [ -z "$base_session_id" ] || [ "$base_session_id" = "null" ]; then
echo "Error: Base session not found. Run 'kugetsu init' first." >&2 log "error" "ensure_worktree" "Base session not found for $issue_ref"
exit 1 echo "error"
return 1
fi fi
local active_count=$(count_active_dev_sessions)
if [ "$active_count" -ge "${MAX_CONCURRENT_AGENTS:-3}" ]; then
log "error" "ensure_worktree" "Max concurrent agents reached for $issue_ref"
echo "error"
return 1
fi
if create_worktree "$issue_ref" "$WORKTREES_DIR" 2>&1 | tee >(cat >&2); then
log "info" "ensure_worktree" "Created worktree for $issue_ref"
echo "created"
return 0
else
log "error" "ensure_worktree" "Failed to create worktree for $issue_ref"
echo "error"
return 1
fi
}
ensure_session() {
local issue_ref="$1"
local session_file=$(issue_ref_to_filename "$issue_ref") local session_file=$(issue_ref_to_filename "$issue_ref")
local session_path="$SESSIONS_DIR/$session_file" local session_path="$SESSIONS_DIR/$session_file"
local worktree_exists=false
if worktree_exists "$issue_ref"; then local worktree_exists=false
if worktree_exists "$issue_ref" "$WORKTREES_DIR"; then
worktree_exists=true worktree_exists=true
fi fi
@@ -361,39 +411,41 @@ cmd_start() {
session_exists=true session_exists=true
fi fi
if $worktree_exists && $session_exists; then if [ "$worktree_exists" = true ] && [ "$session_exists" = true ]; then
echo "Issue '$issue_ref' already has a worktree and session." >&2 log "info" "ensure_session" "Session already exists for $issue_ref"
echo "Use 'kugetsu continue $issue_ref' to continue work." >&2 echo "continued"
exit 1 return 0
fi fi
if $worktree_exists && ! $session_exists; then if [ "$worktree_exists" = false ] && [ "$session_exists" = true ]; then
echo "Warning: Worktree exists but session is missing. Removing worktree to recreate both..." >&2 log "warn" "ensure_session" "Session exists but worktree is missing. Removing stale session..."
remove_worktree_for_issue "$issue_ref"
worktree_exists=false
fi
if ! $worktree_exists && $session_exists; then
echo "Warning: Session exists but worktree is missing. Removing stale session to recreate both..." >&2
rm -f "$session_path" rm -f "$session_path"
remove_issue_from_index "$issue_ref" remove_issue_from_index "$issue_ref"
session_exists=false session_exists=false
fi fi
local active_count=$(count_active_dev_sessions) if [ "$worktree_exists" = false ]; then
if [ "$active_count" -ge "${MAX_CONCURRENT_AGENTS:-3}" ]; then local wt_status=$(ensure_worktree "$issue_ref")
echo "Error: Max concurrent agents (${MAX_CONCURRENT_AGENTS:-3}) reached. Use 'kugetsu continue' or wait for an agent to finish." >&2 if [ "$wt_status" != "created" ] && [ "$wt_status" != "existed" ]; then
exit 1 log "error" "ensure_session" "Failed to ensure worktree for $issue_ref"
echo "error"
return 1
fi
fi fi
create_worktree "$issue_ref" "$WORKTREES_DIR" local base_session_id=$(get_base_session_id)
if [ -z "$base_session_id" ] || [ "$base_session_id" = "null" ]; then
log "error" "ensure_session" "Base session not found for $issue_ref"
echo "error"
return 1
fi
local new_session_id=$(create_session "$base_session_id") local new_session_id=$(create_session "$base_session_id")
if [ -z "$new_session_id" ]; then if [ -z "$new_session_id" ]; then
echo "Error: Could not create session" >&2 log "error" "ensure_session" "Could not create session for $issue_ref"
remove_worktree_for_issue "$issue_ref" echo "error"
exit 1 return 1
fi fi
local worktree_path=$(issue_ref_to_worktree_path "$issue_ref") local worktree_path=$(issue_ref_to_worktree_path "$issue_ref")
@@ -403,89 +455,91 @@ cmd_start() {
add_issue_to_index "$issue_ref" "$session_file" add_issue_to_index "$issue_ref" "$session_file"
local dev_message=$(build_dev_agent_message "$issue_ref" "$message") log "info" "ensure_session" "Created session for $issue_ref: $new_session_id"
echo "created"
load_agent_env "dev" return 0
cd "$worktree_path"
local sanitized_id=$(echo "$new_session_id" | sed 's/[^a-zA-Z0-9_-]/_/g')
mkdir -p "$worktree_path/.kugetsu"
if [ ! -f "$worktree_path/.gitignore" ] || ! grep -q "^.kugetsu/" "$worktree_path/.gitignore"; then
echo ".kugetsu/" >> "$worktree_path/.gitignore" 2>/dev/null || true
fi
local msg_file="$worktree_path/.kugetsu/msg.txt"
printf '%s' "$dev_message" > "$msg_file"
nohup sh -c "GITEA_TOKEN='${GITEA_TOKEN:-}' opencode run '@$msg_file' --session '$new_session_id'" >> "$LOGS_DIR/dev-$sanitized_id.log" 2>&1 &
echo "Session started for '$issue_ref': $new_session_id"
echo "Worktree: $worktree_path"
} }
cmd_continue() { fork_agent() {
local session_name="" local session_id="$1"
local message="" local worktree_path="$2"
local args=("$@") local message="$3"
args=$(set_debug_mode "${args[@]}")
for arg in $args; do
if [ -z "$session_name" ]; then
session_name="$arg"
else
message="$arg"
fi
done
if [ -z "$session_name" ]; then if [ -z "$worktree_path" ] || [ ! -d "$worktree_path" ]; then
echo "Error: issue ref is required" >&2 log "error" "fork_agent" "Invalid worktree path: $worktree_path"
echo "Usage: kugetsu continue <issue-ref> [message]" >&2 echo "error"
exit 1 return 1
fi
validate_issue_ref "$session_name"
local session_file=$(get_session_for_issue "$session_name")
if [ -z "$session_file" ] || [ "$session_file" = "null" ]; then
echo "Error: No session found for '$session_name'" >&2
echo "Use 'kugetsu start $session_name' to create a new session." >&2
exit 1
fi
local session_path="$SESSIONS_DIR/$session_file"
if [ ! -f "$session_path" ]; then
echo "Error: Session file not found: $session_path" >&2
exit 1
fi fi
load_agent_env "dev" load_agent_env "dev"
local opencode_session_id=$(python3 -c "import json; print(json.load(open('$session_path')).get('opencode_session_id', ''))" 2>/dev/null || echo "")
local worktree_path=$(python3 -c "import json; print(json.load(open('$session_path')).get('worktree_path', ''))" 2>/dev/null || echo "")
local issue_ref=$(python3 -c "import json; print(json.load(open('$session_path')).get('issue_ref', ''))" 2>/dev/null || echo "")
if [ -z "$worktree_path" ] || [ ! -d "$worktree_path" ]; then
echo "Warning: Worktree is missing for '$session_name'. Recovering..." >&2
rm -f "$session_path"
remove_issue_from_index "$session_name"
echo "Calling cmd_start to create new session and worktree..." >&2
cmd_start "$session_name" "$message"
return $?
fi
if [ -z "$message" ]; then
message=$(build_dev_agent_message "$issue_ref" "")
fi
cd "$worktree_path" cd "$worktree_path"
local sanitized_id=$(echo "$opencode_session_id" | sed 's/[^a-zA-Z0-9_-]/_/g') local sanitized_id=$(echo "$session_id" | sed 's/[^a-zA-Z0-9_-]/_/g')
mkdir -p "$worktree_path/.kugetsu" mkdir -p "$worktree_path/.kugetsu"
if [ ! -f "$worktree_path/.gitignore" ] || ! grep -q "^.kugetsu/" "$worktree_path/.gitignore"; then if [ ! -f "$worktree_path/.gitignore" ] || ! grep -q "^.kugetsu/" "$worktree_path/.gitignore"; then
echo ".kugetsu/" >> "$worktree_path/.gitignore" 2>/dev/null || true echo ".kugetsu/" >> "$worktree_path/.gitignore" 2>/dev/null || true
fi fi
local msg_file="$worktree_path/.kugetsu/msg.txt" local msg_file="$worktree_path/.kugetsu/msg.txt"
printf '%s' "$message" > "$msg_file" printf '%s' "$message" > "$msg_file"
nohup sh -c "GITEA_TOKEN='${GITEA_TOKEN:-}' opencode run '@$msg_file' --session '$opencode_session_id'" >> "$LOGS_DIR/dev-$sanitized_id.log" 2>&1 & nohup sh -c "GITEA_TOKEN='${GITEA_TOKEN:-}' opencode run '@$msg_file' --session '$session_id'" >> "$LOGS_DIR/dev-$sanitized_id.log" 2>&1 &
log "info" "fork_agent" "Forked agent for session $session_id in $worktree_path"
echo "forked"
return 0
}
cmd_start() {
cmd_continue "$@"
}
cmd_continue() {
local issue_ref="${1:-}"
local message="${2:-}"
if [ -z "$issue_ref" ]; then
echo "Error: issue ref is required" >&2
echo "Usage: kugetsu continue <issue-ref> [message]" >&2
exit 1
fi
validate_issue_ref "$issue_ref"
if [ -z "$message" ]; then
message=$(build_dev_agent_message "$issue_ref" "")
else
message=$(build_dev_agent_message "$issue_ref" "$message")
fi
local worktree_status=$(ensure_worktree "$issue_ref")
if [ "$worktree_status" = "error" ]; then
echo "Error: Failed to ensure worktree for '$issue_ref'" >&2
exit 1
fi
local session_status=$(ensure_session "$issue_ref")
if [ "$session_status" = "error" ]; then
echo "Error: Failed to ensure session for '$issue_ref'" >&2
exit 1
fi
kugetsu_context_dump "$issue_ref" "$message" "$(issue_ref_to_branch_name "$issue_ref")"
local session_file=$(issue_ref_to_filename "$issue_ref")
local session_path="$SESSIONS_DIR/$session_file"
local opencode_session_id=$(python3 -c "import json; print(json.load(open('$session_path')).get('opencode_session_id', ''))" 2>/dev/null || echo "")
local worktree_path=$(python3 -c "import json; print(json.load(open('$session_path')).get('worktree_path', ''))" 2>/dev/null || echo "")
local fork_status=$(fork_agent "$opencode_session_id" "$worktree_path" "$message")
if [ "$fork_status" = "error" ]; then
echo "Error: Failed to fork agent for '$issue_ref'" >&2
exit 1
fi
log "info" "cmd_continue" "Result for $issue_ref: worktree=$worktree_status session=$session_status fork=$fork_status"
echo "Session continued for '$issue_ref': $opencode_session_id"
echo "Worktree: $worktree_path"
echo "${worktree_status}-${session_status}-${fork_status}"
} }
cmd_list() { cmd_list() {

View File

@@ -76,7 +76,8 @@ create_worktree() {
exit 1 exit 1
fi fi
local worktree_parent_dir=$(dirname "$worktree_path") local worktree_parent_dir
worktree_parent_dir=$(dirname "$worktree_path")
mkdir -p "$worktree_parent_dir" mkdir -p "$worktree_parent_dir"
if worktree_exists "$issue_ref" "$parent_dir"; then if worktree_exists "$issue_ref" "$parent_dir"; then
@@ -85,15 +86,36 @@ create_worktree() {
fi fi
echo "Creating worktree at '$worktree_path'..." echo "Creating worktree at '$worktree_path'..."
git clone "$repo_url" "$worktree_path" 2>/dev/null || {
echo "Error: Failed to clone repository" >&2 local clone_success=false
local attempt=0
local max_attempts="${NETWORK_RETRY_ATTEMPTS:-3}"
while [ $attempt -lt $max_attempts ] && [ "$clone_success" = false ]; do
attempt=$((attempt + 1))
if [ $attempt -gt 1 ]; then
echo "Clone attempt $attempt of $max_attempts..."
sleep "$((attempt * 2))"
fi
if git clone "$repo_url" "$worktree_path" 2>/dev/null; then
clone_success=true
fi
done
if [ "$clone_success" = false ]; then
echo "Error: Failed to clone repository after $max_attempts attempts" >&2
exit 1 exit 1
} fi
echo "Creating branch '$branch_name'..." echo "Creating branch '$branch_name'..."
(cd "$worktree_path" && git checkout -b "$branch_name" origin/main 2>/dev/null || git checkout -b "$branch_name" main 2>/dev/null) || { if git -C "$worktree_path" checkout -b "$branch_name" "$KUGETSU_BASE_BRANCH" 2>/dev/null; then
:
elif git -C "$worktree_path" checkout -b "$branch_name" main 2>/dev/null; then
:
else
echo "Warning: Could not checkout branch (may need to run from within worktree after session)" >&2 echo "Warning: Could not checkout branch (may need to run from within worktree after session)" >&2
} fi
echo "Worktree created at: $worktree_path" echo "Worktree created at: $worktree_path"
} }
@@ -145,15 +167,17 @@ check_pr_status() {
token="${GITEA_TOKEN:-}" token="${GITEA_TOKEN:-}"
fi fi
local response local response_file="$KUGETSU_DIR/.pr_status_response_$$.json"
if [ -n "$token" ]; then if [ -n "$token" ]; then
response=$(curl -s -H "Authorization: token $token" "$api_url" 2>/dev/null || echo "{}") curl -s -H "Authorization: token $token" "$api_url" > "$response_file" 2>/dev/null || printf '{}' > "$response_file"
else else
response=$(curl -s "$api_url" 2>/dev/null || echo "{}") curl -s "$api_url" > "$response_file" 2>/dev/null || printf '{}' > "$response_file"
fi fi
local state=$(echo "$response" | python3 -c "import json, sys; d=json.load(sys.stdin); print(d.get('state', 'unknown'))" 2>/dev/null || echo "unknown") local state=$(python3 -c "import json; print(json.load(open('$response_file')).get('state', 'unknown'))" 2>/dev/null || echo "unknown")
local merged=$(echo "$response" | python3 -c "import json, sys; d=json.load(sys.stdin); print('true' if d.get('merged', False) else 'false')" 2>/dev/null || echo "false") local merged=$(python3 -c "import json; print('true' if json.load(open('$response_file')).get('merged', False) else 'false')" 2>/dev/null || echo "false")
rm -f "$response_file"
if [ "$merged" = "true" ]; then if [ "$merged" = "true" ]; then
echo "merged" echo "merged"