Compare commits

..

1 Commits

Author SHA1 Message Date
shokollm
3a0983c28a fix: add PR merge conflict check to dev agent workflow
Dev agent should check if PR has merge conflicts before asking for review.
This prevents approving a PR that has conflicts, which could introduce
new errors after rebase.

Changes:
- Add conflict check step in workflow for both with/without user message cases
- Use mergeable field from PR API to detect conflicts
- Instructions to rebase and resolve conflicts if mergeable: false
2026-04-08 02:15:37 +00:00
10 changed files with 231 additions and 631 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,61 +0,0 @@
# Exit Codes
This document describes the exit codes used by kugetsu commands.
## Exit Codes
| Exit Code | Meaning |
|-----------|---------|
| 0 | Success - operation completed successfully |
| 1 | General error - worktree/session/validation failed |
| 2 | Max concurrent agents reached (MAX_CONCURRENT_AGENTS limit) |
## Commands
### cmd_continue / cmd_start
The `cmd_continue` command (aliased to `cmd_start`) returns exit codes to indicate the result of the operation:
- **Exit 0**: Success - agent forked successfully
- **Exit 1**: General error - worktree/session/validation failed
- **Exit 2**: Max concurrent agents reached
### Internal Functions
#### fork_agent()
Forks a new agent session for a given worktree.
- **Return 0**: Success - agent forked successfully
- **Return 1**: General error - invalid worktree path or other failure
#### ensure_worktree()
Ensures a worktree exists for the given issue reference.
- **Return 0**: Success - worktree existed or was created
- **Return 1**: General error - base session not found or worktree creation failed
- **Return 2**: Max concurrent agents reached
## Daemon Integration
These exit codes are designed to help the queue daemon distinguish between recoverable and non-recoverable errors:
- **Exit 2 (max agents)**: This is a recoverable error - the daemon can retry later when agents become available
- **Exit 1 (general error)**: Non-recoverable - the task should be marked as failed
## MAX_CONCURRENT_AGENTS
The maximum number of concurrent development agents is controlled by the `MAX_CONCURRENT_AGENTS` configuration variable (default: 3).
Set this in your `config` file:
```bash
MAX_CONCURRENT_AGENTS=5
```
Or via environment variable:
```bash
export MAX_CONCURRENT_AGENTS=5
```

View File

@@ -364,14 +364,10 @@ kugetsu queue-daemon logs # Show recent daemon logs
**Daemon Behavior:**
1. Runs at configurable interval (default: 5 minutes)
2. Checks queue for pending items
3. For each pending item:
- Acquires lock to prevent duplicate processing
- Sources kugetsu-session.sh and calls `cmd_continue`
- `cmd_continue` handles worktree/session creation and forks dev agent from base session
- Updates queue item state to "notified"
4. Uses per-issue locking to prevent race conditions
5. Implements timeout for tasks that don't complete (marks as "error" after TASK_TIMEOUT_HOURS)
2. Checks if active agents < MAX_CONCURRENT_AGENTS
3. Picks 1-N pending items (configurable batch size)
4. Forks PM session for each picked item
5. PM decides whether to use `start` or `continue`
**Queue Directory:**
```
@@ -530,13 +526,6 @@ The script will:
See [docs/kugetsu-setup.md](../../docs/kugetsu-setup.md) for full Tailscale setup documentation.
## Version History
### v0.2.3
- **Queue daemon context drift fix** (issue #156): Daemon now sources kugetsu-session.sh and calls `cmd_continue` directly instead of forking PM session. This fixes context drift where daemon would lose track of task state.
- **Daemon locking**: Added per-issue locking to prevent race conditions when multiple daemon instances run.
- **Timeout handling**: Tasks that don't complete within TASK_TIMEOUT_HOURS are marked as "error".
## Without kugetsu
If kugetsu is not available, use opencode directly:

View File

@@ -17,129 +17,77 @@ usage() {
kugetsu - OpenCode Session Manager (Issue-Driven)
Usage:
kugetsu <command> [subcommand] [options]
Commands:
init [--force] Initialize base + pm-agent sessions (requires TTY)
start <issue-ref> <message> [--debug] Start task for issue (forks base session)
continue <issue-ref> [message] [--debug] Continue existing task for issue
delegate <message> Send message to PM agent (fire-and-forget)
logs [n] Show recent delegation logs (default: 10)
status Check kugetsu initialization status
doctor [--fix] Diagnose and fix kugetsu issues
notify [list|clear] Show or clear notifications
list List all tracked sessions
prune [--force] Remove orphaned sessions
destroy <target> [-y] Delete session (issue, pm-agent, or base)
set-pr <issue-ref> <pr-url> Set PR URL for session
context <issue-ref> Show context for issue
queue [subcommand] Queue management
queue-daemon [subcommand] Queue daemon management
env [subcommand] Environment variable management
server [subcommand] Git server configuration
help [command] Show help for a command
Use 'kugetsu <command> help' for subcommand help.
Example: kugetsu queue help, kugetsu queue-daemon help
kugetsu init [--force] Initialize base + pm-agent sessions (requires TTY)
kugetsu start <issue-ref> <message> [--debug] Start task for issue (forks base session)
kugetsu continue <issue-ref> [message] [--debug] Continue existing task for issue
kugetsu delegate <message> Send message to PM agent (fire-and-forget)
kugetsu logs [n] Show recent delegation logs (default: 10)
kugetsu status Check kugetsu initialization status
kugetsu doctor [--fix] Diagnose and fix kugetsu issues
kugetsu notify [list|clear] Show or clear notifications
kugetsu list List all tracked sessions
kugetsu prune [--force] Remove orphaned sessions (keeps base + pm-agent)
kugetsu destroy <issue-ref> [-y] Delete session for issue
kugetsu destroy --pm-agent [-y] Delete pm-agent session (not recommended)
kugetsu destroy --base [-y] Delete base session
kugetsu set-pr <issue-ref> <pr-url> Set PR URL for session (for PR tracking)
kugetsu context <issue-ref> Show context for issue
kugetsu queue [list|stats|clear] Show queue status or statistics
kugetsu queue enqueue <issue-ref> <message> Enqueue a task (normally via delegate)
kugetsu queue-daemon [start|stop|restart|status|logs] Manage queue daemon
kugetsu env [get|set|list] Manage agent environment variables
kugetsu server [list|add|remove|default|get] Manage git server configurations
kugetsu help Show this help
Issue Ref Format:
instance/user/repo#number
Example: github.com/shoko/kugetsu#14
EOF
}
usage_queue() {
cat << 'EOF'
kugetsu queue - Queue management
Commands:
init Create base + pm-agent sessions via TUI. Requires terminal access.
Use --force to reinitialize if sessions exist.
start Fork new session from base for specific issue.
Requires pm-agent to be running (created by init).
continue Continue work on existing issue session.
delegate Send message to PM agent for task coordination.
Fire-and-forget: returns immediately, runs in background.
Use 'kugetsu logs' to check output.
logs Show recent delegation logs.
Default: 10 most recent. Use 'kugetsu logs 20' for more.
status Check if kugetsu is initialized and PM agent is active.
doctor Diagnose kugetsu issues. Use --fix to attempt repairs.
notify Show or clear notifications from PM agent.
Use 'kugetsu notify list' to see unread notifications.
list Show all sessions (base + pm-agent + forked issues).
prune Remove sessions not in index (orphaned from opencode).
Use --force to skip confirmation.
destroy Delete specific issue, pm-agent, or base session.
Usage:
kugetsu queue [subcommand]
Options:
--debug Show real-time debug output and capture to debug.log
Subcommands:
list [--limit=N] [--format=json] Show pending tasks (default: 10 items, text format)
stats Show queue statistics
clear Clear all queue items
enqueue <issue-ref> <message> Enqueue a task
help Show this help
PM Context:
kugetsu reads ~/.kugetsu/pm-agent.md (if exists) and injects it
into the PM agent session at init time. This allows customizing PM
behavior without recreating the session.
Options for list:
--limit=N Number of items to return (default: 10)
--format=json Output in JSON format with stats
Notifications:
PM Agent writes task completion notifications to ~/.kugetsu/notifications.json
Use 'kugetsu notify list' to see unread notifications.
Examples:
kugetsu queue list
kugetsu queue list --limit=50
kugetsu queue list --format=json
kugetsu queue list --limit=20 --format=json
kugetsu queue stats
kugetsu queue clear
kugetsu queue enqueue github.com/shoko/kugetsu#14 "fix bug"
EOF
}
usage_queue_daemon() {
cat << 'EOF'
kugetsu queue-daemon - Queue daemon management
Usage:
kugetsu queue-daemon [subcommand]
Subcommands:
start Start the queue daemon
stop Stop the queue daemon
restart Restart the queue daemon
status Check daemon status
logs Show recent daemon logs
help Show this help
Examples:
kugetsu queue-daemon start
kugetsu queue-daemon status
kugetsu queue-daemon logs
EOF
}
usage_env() {
cat << 'EOF'
kugetsu env - Environment variable management
Usage:
kugetsu env [subcommand]
Subcommands:
list List all environment variables
get <key> Get a specific variable
set <key> <value> Set a variable
rm <key> Remove a variable
help Show this help
Examples:
kugetsu env list
kugetsu env get GITEA_TOKEN
kugetsu env set CUSTOM_VAR "value"
kugetsu env rm CUSTOM_VAR
EOF
}
usage_server() {
cat << 'EOF'
kugetsu server - Git server configuration
Usage:
kugetsu server [subcommand]
Subcommands:
list List all configured servers (default)
add <name> <url> Add a new server
remove <name> Remove a server
default [<name>] Get or set default server
get [<name>] Get server URL
help Show this help
Examples:
kugetsu server list
kugetsu server add github.com https://github.com
kugetsu server default github.com
kugetsu init
kugetsu status
kugetsu delegate "work on issue #5"
kugetsu logs
kugetsu logs 20
kugetsu doctor
kugetsu doctor --fix
kugetsu notify list
kugetsu notify clear
kugetsu start github.com/shoko/kugetsu#14 "fix bug"
kugetsu continue github.com/shoko/kugetsu#14 "add tests"
kugetsu list
EOF
}
@@ -362,25 +310,19 @@ PYEOF
get_pending_tasks() {
local limit="${1:-10}"
local format="${2:-text}"
if [ ! -d "$QUEUE_ITEMS_DIR" ]; then
if [ "$format" = "json" ]; then
echo '{"items": [], "total": 0, "pending": 0}'
else
echo "No pending tasks in queue."
fi
echo "[]"
return
fi
python3 << PYEOF
python3 -c "
import json
import os
import sys
queue_dir = os.environ.get('QUEUE_ITEMS_DIR', '')
limit = int(sys.argv[1]) if len(sys.argv) > 1 else 10
format_type = sys.argv[2] if len(sys.argv) > 2 else 'text'
items = []
if os.path.isdir(queue_dir):
@@ -392,27 +334,13 @@ if os.path.isdir(queue_dir):
data = json.load(f)
if data.get('state') == 'pending':
items.append(data)
if len(items) >= limit:
break
except:
pass
items.sort(key=lambda x: x.get('pending_since', ''))
if format_type == 'json':
output = {
"items": items[:limit],
"total": len(items),
"pending": len(items)
}
print(json.dumps(output))
else:
if not items:
print("No pending tasks in queue.")
else:
print("Pending tasks:")
for t in items[:limit]:
msg = t.get('message', '')[:50]
print(f" {t.get('id')}: {t.get('issue_ref')} - {msg}...")
PYEOF
print(json.dumps(items))
" "$limit"
}
get_queue_stats() {
@@ -937,34 +865,18 @@ find_sessions_by_issue_number() {
}
cmd_queue() {
local action="${1:-}"
local action="${1:-list}"
shift
case "$action" in
""|help|--help|-h)
usage_queue
;;
help|--help|-h)
usage_queue
;;
list)
local limit="10"
local format="text"
while [ $# -gt 0 ]; do
case "$1" in
--limit=*)
limit="${1#*=}"
;;
--format=json)
format="json"
;;
*)
;;
esac
shift
done
get_pending_tasks "$limit" "$format"
local pending_tasks=$(get_pending_tasks 10)
if [ "$pending_tasks" = "[]" ]; then
echo "No pending tasks in queue."
else
echo "Pending tasks:"
echo "$pending_tasks" | python3 -c "import sys, json; [print(f\" {t.get('id')}: {t.get('issue_ref')} - {t.get('message', '')[:50]}...\") for t in json.load(sys.stdin)]"
fi
;;
stats)
local stats=$(get_queue_stats)
@@ -986,8 +898,8 @@ cmd_queue() {
echo "Queue cleared."
;;
enqueue)
local issue_ref="${2:-}"
local message="${3:-}"
local issue_ref="${1:-}"
local message="${2:-}"
if [ -z "$issue_ref" ] || [ -z "$message" ]; then
echo "Usage: kugetsu queue enqueue <issue-ref> <message>" >&2
exit 1
@@ -998,20 +910,16 @@ cmd_queue() {
check_task_timeouts
;;
*)
echo "Unknown queue subcommand: $action" >&2
usage_queue
echo "Usage: kugetsu queue [list|stats|clear|enqueue]" >&2
exit 1
;;
esac
}
cmd_queue_daemon() {
local action="${1:-}"
local action="${1:-status}"
case "$action" in
""|help|--help|-h)
usage_queue_daemon
;;
start)
if [ -f "$QUEUE_DAEMON_PID_FILE" ]; then
local old_pid=$(cat "$QUEUE_DAEMON_PID_FILE")
@@ -1070,8 +978,7 @@ cmd_queue_daemon() {
fi
;;
*)
echo "Unknown queue-daemon subcommand: $action" >&2
usage_queue_daemon
echo "Usage: kugetsu queue-daemon [start|stop|restart|status|logs]" >&2
exit 1
;;
esac
@@ -1152,12 +1059,10 @@ set_debug_mode() {
}
cmd_env() {
local action="${1:-}"
local action="${1:-list}"
shift
case "$action" in
""|help|--help|-h)
usage_env
;;
list)
echo "Agent environment variables:"
if [ -d "$ENV_DIR" ]; then
@@ -1175,7 +1080,7 @@ cmd_env() {
fi
;;
get)
local key="${2:-}"
local key="${1:-}"
if [ -z "$key" ]; then
echo "Usage: kugetsu env get <key>" >&2
exit 1
@@ -1190,8 +1095,8 @@ cmd_env() {
fi
;;
set)
local key="${2:-}"
local value="${3:-}"
local key="${1:-}"
local value="${2:-}"
if [ -z "$key" ] || [ -z "$value" ]; then
echo "Usage: kugetsu env set <key> <value>" >&2
exit 1
@@ -1200,8 +1105,8 @@ cmd_env() {
echo "${key}=${value}" >> "$ENV_DIR/default.env"
echo "Set $key in $ENV_DIR/default.env"
;;
rm|remove)
local key="${2:-}"
rm)
local key="${1:-}"
if [ -z "$key" ]; then
echo "Usage: kugetsu env rm <key>" >&2
exit 1
@@ -1212,8 +1117,7 @@ cmd_env() {
fi
;;
*)
echo "Unknown env subcommand: $action" >&2
usage_env
echo "Usage: kugetsu env [list|get|set|rm]" >&2
exit 1
;;
esac
@@ -1223,10 +1127,7 @@ cmd_server() {
local action="${1:-}"
case "$action" in
""|help|--help|-h)
usage_server
;;
"list")
""|"list")
if [ -z "${GIT_SERVERS+x}" ]; then
echo "No git servers configured"
return
@@ -1303,8 +1204,14 @@ cmd_server() {
fi
;;
*)
echo "Unknown server subcommand: $action" >&2
usage_server
echo "Usage: kugetsu server <list|add|remove|default|get>" >&2
echo "" >&2
echo "Commands:" >&2
echo " list List all configured git servers" >&2
echo " add <name> <url> Add a new git server" >&2
echo " remove <name> Remove a git server" >&2
echo " default [<name>] Get or set default server" >&2
echo " get [<name>] Get URL for a server (default: current default)" >&2
exit 1
;;
esac
@@ -1414,29 +1321,7 @@ main() {
case "$command" in
help|--help|-h)
local subcommand="${1:-}"
case "$subcommand" in
queue|"")
usage_queue
;;
queue-daemon)
usage_queue_daemon
;;
env)
usage_env
;;
server)
usage_server
;;
"")
usage
;;
*)
echo "Help not available for '$subcommand'" >&2
usage
exit 1
;;
esac
usage
;;
init)
cmd_init "$@"

View File

@@ -26,10 +26,6 @@ QUEUE_DAEMON_INTERVAL_MINUTES="${QUEUE_DAEMON_INTERVAL_MINUTES:-5}"
QUEUE_CLEANUP_AGE_DAYS="${QUEUE_CLEANUP_AGE_DAYS:-7}"
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)
if [ -f "$KUGETSU_DIR/config" ]; then
source "$KUGETSU_DIR/config"
@@ -91,24 +87,3 @@ set_debug_mode() {
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,77 +139,6 @@ validate_issue_ref() {
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() {
local issue_ref="$1"
local pr_url="$2"

View File

@@ -45,61 +45,22 @@ cmd_logs() {
find "$LOGS_DIR" -type f -mtime +7 -delete 2>/dev/null
local log_files=()
while IFS= read -r line; do
log_files+=("$line")
done < <(ls -lt "$LOGS_DIR" | head -$((count + 1)) | tail -$count | awk '{print $NF}')
echo "=== Recent Logs ($count files) ==="
echo ""
printf "%-30s %8s %s\n" "FILE" "SIZE" "LAST MODIFIED"
echo "----------------------------------------------------------------"
for log in "${log_files[@]}"; do
if [ -f "$LOGS_DIR/$log" ]; then
local size=$(stat -c %s "$LOGS_DIR/$log" 2>/dev/null || echo "0")
local mtime=$(stat -c %Y "$LOGS_DIR/$log" 2>/dev/null || echo "0")
local date_str=$(date -d "@$mtime" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "unknown")
printf "%-30s %8s %s\n" "$log" "$size" "$date_str"
fi
ls -lt "$LOGS_DIR" | head -$((count + 1)) | tail -$count | while read line; do
echo "$line"
done
echo ""
echo "Log Contents:"
echo "================================================================"
echo "Recent log contents:"
echo "===================="
for log in "${log_files[@]}"; do
for log in $(ls -lt "$LOGS_DIR" | head -$((count + 1)) | tail -$count | awk '{print $NF}'); do
if [ -f "$LOGS_DIR/$log" ]; then
echo ""
echo ">>> $log <<<"
echo "----------------------------------------------------------------"
echo "--- $log ---"
tail -20 "$LOGS_DIR/$log" | while read line; do
line=$(strip_ansi_codes "$line")
line=$(mask_sensitive_vars "$line")
if [[ "$line" =~ \[([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}[^\]]*)\]\ ([a-z]+)\ (.+) ]]; then
local ts="${BASH_REMATCH[1]}"
local level="${BASH_REMATCH[2]}"
local msg="${BASH_REMATCH[3]}"
local color=""
local level_str=""
case "$level" in
error) color="\033[0;31m"; level_str="[ERROR]" ;;
warn) color="\033[0;33m"; level_str="[WARN ]" ;;
info) color="\033[0;32m"; level_str="[INFO ]" ;;
debug) color="\033[0;36m"; level_str="[DEBUG]" ;;
*) color="\033[0;90m"; level_str="[$level]" ;;
esac
if [ -t 1 ]; then
echo -e " \033[0;90m$ts\033[0m ${color}${level_str}\033[0m $msg"
else
echo " $ts ${level_str} $msg"
fi
else
echo " $line"
fi
echo " $line"
done
fi
done

View File

@@ -34,8 +34,6 @@ release_lock() {
check_task_completion() {
local item="$1"
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)
[ "$state" = "notified" ] || return 0
@@ -43,31 +41,6 @@ check_task_completion() {
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 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 ! kill -0 "$pid" 2>/dev/null; then
@@ -75,7 +48,7 @@ check_task_completion() {
local has_commits=false
if [ -d "$worktree_path" ] && [ -d "$worktree_path/.git" ]; then
if [ -n "$(git -C "$worktree_path" log --oneline "$KUGETSU_BASE_BRANCH..HEAD" 2>/dev/null)" ]; then
if [ -n "$(git -C "$worktree_path" log --oneline origin/main..HEAD 2>/dev/null)" ]; then
has_commits=true
fi
fi
@@ -95,7 +68,7 @@ check_task_completion() {
local has_commits=false
if [ -d "$worktree_path" ] && [ -d "$worktree_path/.git" ]; then
if [ -n "$(git -C "$worktree_path" log --oneline "$KUGETSU_BASE_BRANCH..HEAD" 2>/dev/null)" ]; then
if [ -n "$(git -C "$worktree_path" log --oneline origin/main..HEAD 2>/dev/null)" ]; then
has_commits=true
fi
fi

View File

@@ -13,8 +13,7 @@ count_active_dev_sessions() {
if [ -d "$SESSIONS_DIR" ]; then
for session_file in "$SESSIONS_DIR"/*.json; do
if [ -f "$session_file" ]; then
local filename
filename=$(basename "$session_file")
local filename=$(basename "$session_file")
if [ "$filename" != "base.json" ] && [ "$filename" != "pm-agent.json" ]; then
count=$((count + 1))
fi
@@ -228,7 +227,8 @@ cmd_delegate() {
local msg_file="$LOGS_DIR/msg-$new_session.txt"
printf '%s' "$message" > "$msg_file"
sh -c "GITEA_TOKEN='${GITEA_TOKEN:-}' opencode run '@$msg_file' --session '$new_session'" 2>&1 | tee "$log_file"
nohup sh -c "GITEA_TOKEN='${GITEA_TOKEN:-}' opencode run '@$msg_file' --session '$new_session'" >> "$log_file" 2>&1 &
echo "Delegated to new session (logged to $(basename "$log_file"))"
}
create_session() {
@@ -239,55 +239,23 @@ create_session() {
return 1
fi
local before_file
before_file="$KUGETSU_DIR/sessions/before$$.json"
local after_file
after_file="$KUGETSU_DIR/sessions/after$$.json"
local before_json=$(opencode session list --format=json 2>/dev/null)
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 "|")
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
opencode run --fork --session "$base_session" "new session" >/dev/null 2>&1
sleep 1
opencode session list --format=json > "$after_file" 2>/dev/null || printf '{}' > "$after_file"
local after_json=$(opencode session list --format=json 2>/dev/null)
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
new_session_id=$(python3 << PYEOF
import json
with open("$before_file", 'r') as f:
before = json.load(f)
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"
local new_session_id=""
while IFS= read -r sess; do
if [[ -n "$sess" ]] && [[ ! "$before_set" =~ \|${sess}\| ]]; then
new_session_id="$sess"
break
fi
done <<< "$after_sessions"
echo "$new_session_id"
}
@@ -302,37 +270,21 @@ build_dev_agent_message() {
local number=$(echo "$issue_ref" | grep -oE '#[0-9]+$' | tr -d '#')
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
conflict_check=" - CRITICAL: Check if PR has merge conflicts before asking for review:
- 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.
Workflow:"
delegator_footer="
Delegator's message:
$user_message"
else
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
cat <<EOF
You are assigned to work on $issue_ref.
$delegator_header
IMPORTANT: Follow the workflow below as your guideline, but prioritize the delegator's message.
Workflow:
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
- If PR exists and is open, review it and learn from it
$conflict_check
- CRITICAL: Check if PR has merge conflicts before asking for review:
- 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
- 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
3. Read README.md (if exists) to understand the general concept of this repository
@@ -350,14 +302,52 @@ 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"}'
- 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"
$review_notes
- 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
- If no approval, ask reviewer to approve first before merging
$delegator_footer
Delegator's message:
$user_message
Work directory: $worktree_path
EOF
else
cat <<EOF
You are assigned to work on $issue_ref.
Workflow:
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
- If PR exists and is open, review it and learn from it
- CRITICAL: Check if PR has merge conflicts before asking for review:
- 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
- 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
3. Read README.md (if exists) to understand the general concept of this repository
4. Read CONTRIBUTING.md (if exists) to understand how to contribute
- If CONTRIBUTING.md doesn't exist, follow steps 5-9 as your guideline
5. Explore the repository to understand the codebase
6. If anything is unclear, post a comment on the issue asking for clarification before implementing
7. Implement the solution
8. Create a branch named fix/issue-$number and implement the fix
9. Create a PR when the implementation is complete using: tea pr create --repo $owner/$repo --title "Your PR title" --body "PR description"
- Make sure you are logged in with: tea login add --name gitea --token \$GITEA_TOKEN --url https://$instance
- If tea is not available, use: curl -X POST "https://$instance/api/v1/repos/$owner/$repo/pulls" -H "Authorization: Bearer \$GITEA_TOKEN" -H "Content-Type: application/json" -d '{"title":"PR Title","head":"branch-name","base":"main","body":"PR description"}'
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"}'
- 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"
- 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
- If no approval, ask reviewer to approve first before merging
Work directory: $worktree_path
EOF
fi
}
ensure_worktree() {
@@ -379,8 +369,8 @@ ensure_worktree() {
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 "max_agents"
return 2
echo "error"
return 1
fi
if create_worktree "$issue_ref" "$WORKTREES_DIR" 2>&1 | tee >(cat >&2); then
@@ -410,20 +400,20 @@ ensure_session() {
session_exists=true
fi
if [ "$worktree_exists" = true ] && [ "$session_exists" = true ]; then
if $worktree_exists && $session_exists; then
log "info" "ensure_session" "Session already exists for $issue_ref"
echo "continued"
return 0
fi
if [ "$worktree_exists" = false ] && [ "$session_exists" = true ]; then
if ! $worktree_exists && $session_exists; then
log "warn" "ensure_session" "Session exists but worktree is missing. Removing stale session..."
rm -f "$session_path"
remove_issue_from_index "$issue_ref"
session_exists=false
fi
if [ "$worktree_exists" = false ]; then
if ! $worktree_exists; then
local wt_status=$(ensure_worktree "$issue_ref")
if [ "$wt_status" != "created" ] && [ "$wt_status" != "existed" ]; then
log "error" "ensure_session" "Failed to ensure worktree for $issue_ref"
@@ -466,6 +456,7 @@ fork_agent() {
if [ -z "$worktree_path" ] || [ ! -d "$worktree_path" ]; then
log "error" "fork_agent" "Invalid worktree path: $worktree_path"
echo "error"
return 1
fi
@@ -474,7 +465,7 @@ fork_agent() {
cd "$worktree_path"
local sanitized_id=$(echo "$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
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"
@@ -482,6 +473,7 @@ fork_agent() {
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
}
@@ -496,10 +488,10 @@ cmd_continue() {
if [ -z "$issue_ref" ]; then
echo "Error: issue ref is required" >&2
echo "Usage: kugetsu continue <issue-ref> [message]" >&2
return 1
exit 1
fi
validate_issue_ref "$issue_ref" || return 1
validate_issue_ref "$issue_ref"
if [ -z "$message" ]; then
message=$(build_dev_agent_message "$issue_ref" "")
@@ -508,35 +500,33 @@ cmd_continue() {
fi
local worktree_status=$(ensure_worktree "$issue_ref")
if [ "$worktree_status" = "max_agents" ]; then
echo "Error: Max concurrent agents reached for '$issue_ref'" >&2
return 2
fi
if [ "$worktree_status" = "error" ]; then
echo "Error: Failed to ensure worktree for '$issue_ref'" >&2
return 1
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
return 1
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 "")
fork_agent "$opencode_session_id" "$worktree_path" "$message" || return 1
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=forked"
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}-forked"
echo "${worktree_status}-${session_status}-${fork_status}"
}
cmd_list() {
@@ -558,71 +548,55 @@ cmd_list() {
fi
echo ""
local issue_sessions=()
echo "Issue sessions:"
if [ -d "$SESSIONS_DIR" ]; then
for session_file in "$SESSIONS_DIR"/*.json; do
if [ -f "$session_file" ]; then
local filename=$(basename "$session_file")
if [ "$filename" != "base.json" ] && [ "$filename" != "pm-agent.json" ]; then
local mtime=$(stat -c %Y "$session_file" 2>/dev/null || echo "0")
local issue_ref=$(filename_to_issue_ref "$filename")
local opencode_sid=$(python3 -c "import json; print(json.load(open('$session_file')).get('opencode_session_id', 'unknown'))" 2>/dev/null || echo "unknown")
local state=$(python3 -c "import json; print(json.load(open('$session_file')).get('state', 'unknown'))" 2>/dev/null || echo "unknown")
local worktree_path=$(python3 -c "import json; print(json.load(open('$session_file')).get('worktree_path', ''))" 2>/dev/null || echo "")
issue_sessions+=("$mtime|$filename|$issue_ref|$opencode_sid|$state|$worktree_path")
local worktree_status=""
if [ -n "$worktree_path" ]; then
if [ -d "$worktree_path" ]; then
worktree_status="(worktree exists)"
else
worktree_status="(worktree MISSING)"
fi
fi
echo " $filename"
echo " Issue: $issue_ref"
echo " Session: $opencode_sid"
echo " State: $state"
echo " $worktree_status"
fi
fi
done
fi
if [ ${#issue_sessions[@]} -gt 0 ]; then
printf "%-45s %-10s %-20s %s\n" "ISSUE_REF" "STATE" "LAST_UPDATED" "SESSION_ID"
echo "-------------------------------------------------------------------------------------------------------------"
while IFS='|' read -r mtime filename issue_ref opencode_sid state worktree_path; do
local last_updated=$(date -d "@$mtime" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "unknown")
local worktree_status=""
if [ -n "$worktree_path" ] && [ ! -d "$worktree_path" ]; then
worktree_status=" [worktree MISSING]"
fi
printf "%-45s %-10s %-20s %s%s\n" "$issue_ref" "$state" "$last_updated" "$opencode_sid" "$worktree_status"
done < <(printf '%s\n' "${issue_sessions[@]}" | sort -t'|' -k1 -rn)
else
echo "No issue sessions found."
fi
echo ""
local orphaned_worktrees=()
if [ -d "$WORKTREES_DIR" ]; then
echo ""
echo "Worktrees without sessions:"
for worktree in $(find "$WORKTREES_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null); do
local worktree_name=$(basename "$worktree")
local has_session=false
if [ -d "$SESSIONS_DIR" ]; then
for session_file in "$SESSIONS_DIR"/*.json; do
if [ -f "$session_file" ]; then
local wt_path=$(python3 -c "import json; print(json.load(open('$session_file')).get('worktree_path', ''))" 2>/dev/null || echo "")
if [ "$wt_path" = "$worktree" ]; then
has_session=true
break
fi
for session_file in "$SESSIONS_DIR"/*.json; do
if [ -f "$session_file" ]; then
local wt_path=$(python3 -c "import json; print(json.load(open('$session_file')).get('worktree_path', ''))" 2>/dev/null || echo "")
if [ "$wt_path" = "$worktree" ]; then
has_session=true
break
fi
done
fi
fi
done
if [ "$has_session" = false ]; then
orphaned_worktrees+=("$worktree_name")
echo " $worktree_name (no session)"
fi
done
fi
if [ ${#orphaned_worktrees[@]} -gt 0 ]; then
echo "Worktrees without sessions:"
for wt in "${orphaned_worktrees[@]}"; do
echo " $wt"
done
fi
}
cmd_prune() {

View File

@@ -76,8 +76,7 @@ create_worktree() {
exit 1
fi
local worktree_parent_dir
worktree_parent_dir=$(dirname "$worktree_path")
local worktree_parent_dir=$(dirname "$worktree_path")
mkdir -p "$worktree_parent_dir"
if worktree_exists "$issue_ref" "$parent_dir"; then
@@ -86,36 +85,15 @@ create_worktree() {
fi
echo "Creating worktree at '$worktree_path'..."
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
git clone "$repo_url" "$worktree_path" 2>/dev/null || {
echo "Error: Failed to clone repository" >&2
exit 1
fi
}
echo "Creating branch '$branch_name'..."
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
(cd "$worktree_path" && git checkout -b "$branch_name" origin/main 2>/dev/null || git checkout -b "$branch_name" main 2>/dev/null) || {
echo "Warning: Could not checkout branch (may need to run from within worktree after session)" >&2
fi
}
echo "Worktree created at: $worktree_path"
}
@@ -167,17 +145,15 @@ check_pr_status() {
token="${GITEA_TOKEN:-}"
fi
local response_file="$KUGETSU_DIR/.pr_status_response_$$.json"
local response
if [ -n "$token" ]; then
curl -s -H "Authorization: token $token" "$api_url" > "$response_file" 2>/dev/null || printf '{}' > "$response_file"
response=$(curl -s -H "Authorization: token $token" "$api_url" 2>/dev/null || echo "{}")
else
curl -s "$api_url" > "$response_file" 2>/dev/null || printf '{}' > "$response_file"
response=$(curl -s "$api_url" 2>/dev/null || echo "{}")
fi
local state=$(python3 -c "import json; print(json.load(open('$response_file')).get('state', 'unknown'))" 2>/dev/null || echo "unknown")
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"
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 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")
if [ "$merged" = "true" ]; then
echo "merged"