Compare commits

...

10 Commits

Author SHA1 Message Date
shokollm
990bc46477 feat(kugetsu): add lock mechanism for worktree coordination
Add lock mechanism to prevent concurrent access to worktrees:
- Add LOCKS_DIR constant (~/.kugetsu/locks)
- Add acquire_lock() - acquires lock file with PID, session_id, timestamp
- Add release_lock() - releases lock if held by current process
- Add release_all_locks() - releases all locks for a session
- Add check_lock() - check if issue is locked
- Modify cmd_start to acquire lock before creating worktree
- Modify cmd_destroy to release lock when destroying session
- Add trap for cleanup on EXIT/INT/TERM

Lock files named after issue ref. Stale lock detection: if PID
no longer exists, lock is considered stale.

Fixes #71
2026-04-02 02:52:16 +00:00
e1050cb70a Merge pull request 'feat(kugetsu): add queue infrastructure for autonomous PM' (#97) from feat/issue-49-queue-v2 into main 2026-04-02 04:37:41 +02:00
caf1e9cdcd Merge pull request 'fix(kugetsu): add fix_session_permissions command for cmd_doctor' (#93) from fix/issue-36-permissions-v2 into main 2026-04-02 04:37:39 +02:00
73f9c03e18 Merge pull request 'fix(kugetsu): export KUGETSU_TEMP_DIR for subagent workflows' (#92) from fix/issue-73-temp-dir-v2 into main 2026-04-02 04:37:25 +02:00
shokollm
b2f2df7b06 test(kugetsu): add comprehensive tests for fix_session_permissions
- Test E7: verify fix_session_permissions function exists
- Test E8: verify cmd_doctor --fix-permissions flag is recognized
- Test E9: verify permission JSON is valid JSON
- Test E10: verify SQL UPDATE syntax works correctly

These tests verify the fix without requiring actual opencode installation.
2026-04-02 02:12:38 +00:00
shokollm
2060c4ffbe test: add fix_session_permissions tests
- Add test E7: verify fix_session_permissions function exists
- Add test E8: verify cmd_doctor --fix-permissions flag is recognized
- Add fix_session_permissions call to cmd_init to set permissions
  when initializing new sessions
2026-04-02 01:37:14 +00:00
shokollm
c0d4314933 test: add KUGETSU_TEMP_DIR export test
Add unit test to verify KUGETSU_TEMP_DIR is exported in cmd_delegate.
Also update SKILL.md to document KUGETSU_TEMP_DIR config option.
2026-04-02 01:18:49 +00:00
shokollm
0f66de2929 feat(kugetsu): add queue infrastructure for autonomous PM
Add queue management system for task queue architecture (Phase 1):

- Add QUEUE_FILE and POLL_INTERVAL constants
- Add init_queue() to initialize queue.json if missing
- Add cmd_queue with subcommands:
  - list: Show queue status with counts per tier
  - enqueue <tier> <msg>: Add task to queue
  - dequeue [tier]: Remove and return next task (priority order)
  - clear: Clear all queued tasks

Queue tiers:
- dev_followups (highest priority)
- user_interrupts (medium)
- background (lowest)

This enables the autonomous queue-based architecture where PM agent
continuously polls the queue and assigns work to dev agents.

Part of #49
2026-04-02 01:04:10 +00:00
shokollm
74468af7c8 fix(kugetsu): add fix_session_permissions command for cmd_doctor
Add --fix-permissions flag to cmd_doctor:
  kugetsu doctor --fix-permissions

The fix_session_permissions() function:
- Updates base session and PM agent session permissions in SQLite
- Sets external_directory pattern to '*' with action 'allow'
- This fixes the issue where PM agent cannot access external directories

This addresses issue #36 where PM agent external_directory permission fails.

Fixes #36
2026-04-02 00:57:14 +00:00
shokollm
e184b1e5b0 fix(kugetsu): export KUGETSU_TEMP_DIR for subagent workflows
Export KUGETSU_TEMP_DIR in cmd_delegate so subagents can use it
instead of /tmp which may be blocked by opencode.

Default: ~/.local/share/opencode/tool-output

This allows agents to write temp files in an allowed directory
instead of /tmp which is blocked in headless mode.

Fixes #73
2026-04-02 00:55:41 +00:00
3 changed files with 326 additions and 1 deletions

View File

@@ -47,6 +47,7 @@ A default config file is created during `kugetsu init` with commented examples:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `MAX_CONCURRENT_AGENTS` | 3 | Maximum number of concurrent dev agents | | `MAX_CONCURRENT_AGENTS` | 3 | Maximum number of concurrent dev agents |
| `KUGETSU_TEMP_DIR` | `~/.local/share/opencode/tool-output` | Temp directory for subagent tool output (useful in headless environments where /tmp is restricted) |
### Environment Variables for Agents ### Environment Variables for Agents

View File

@@ -9,7 +9,10 @@ INDEX_FILE="$KUGETSU_DIR/index.json"
NOTIFICATIONS_FILE="$KUGETSU_DIR/notifications.json" NOTIFICATIONS_FILE="$KUGETSU_DIR/notifications.json"
LOGS_DIR="$KUGETSU_DIR/logs" LOGS_DIR="$KUGETSU_DIR/logs"
ENV_DIR="${ENV_DIR:-$KUGETSU_DIR/env}" ENV_DIR="${ENV_DIR:-$KUGETSU_DIR/env}"
QUEUE_FILE="$KUGETSU_DIR/queue.json"
LOCKS_DIR="$KUGETSU_DIR/locks"
MAX_CONCURRENT_AGENTS="${MAX_CONCURRENT_AGENTS:-3}" MAX_CONCURRENT_AGENTS="${MAX_CONCURRENT_AGENTS:-3}"
POLL_INTERVAL="${POLL_INTERVAL:-600}"
# Load user config overrides (~/.kugetsu/config) # Load user config overrides (~/.kugetsu/config)
if [ -f "$KUGETSU_DIR/config" ]; then if [ -f "$KUGETSU_DIR/config" ]; then
@@ -56,6 +59,87 @@ count_active_dev_sessions() {
echo "$count" echo "$count"
} }
acquire_lock() {
local issue_ref="$1"
local session_id="$2"
local lock_file="$LOCKS_DIR/${issue_ref//[^a-zA-Z0-9._-]/_}.lock"
mkdir -p "$LOCKS_DIR"
if [ -f "$lock_file" ]; then
local lock_pid=$(cat "$lock_file" 2>/dev/null | cut -d: -f1)
local lock_session=$(cat "$lock_file" 2>/dev/null | cut -d: -f2)
if [ -n "$lock_pid" ] && ! kill -0 "$lock_pid" 2>/dev/null; then
echo "Stale lock detected, removing..."
rm -f "$lock_file"
elif [ "$lock_session" = "$session_id" ]; then
echo "Already holding lock for $issue_ref"
return 0
else
echo "Error: $issue_ref is locked by session $lock_session (PID $lock_pid)" >&2
return 1
fi
fi
echo "${BASHPID}:${session_id}:$(date +%s)" > "$lock_file"
echo "Lock acquired for $issue_ref: $lock_file"
return 0
}
release_lock() {
local issue_ref="$1"
local lock_file="$LOCKS_DIR/${issue_ref//[^a-zA-Z0-9._-]/_}.lock"
if [ ! -f "$lock_file" ]; then
return 0
fi
local lock_pid=$(cat "$lock_file" 2>/dev/null | cut -d: -f1)
if [ "$lock_pid" = "$BASHPID" ]; then
rm -f "$lock_file"
echo "Lock released for $issue_ref"
else
echo "Error: Cannot release lock held by PID $lock_pid (current: $BASHPID)" >&2
return 1
fi
}
release_all_locks() {
local session_id="$1"
if [ ! -d "$LOCKS_DIR" ]; then
return 0
fi
for lock_file in "$LOCKS_DIR"/*.lock; do
[ -f "$lock_file" ] || continue
local lock_session=$(cat "$lock_file" 2>/dev/null | cut -d: -f2)
if [ "$lock_session" = "$session_id" ]; then
rm -f "$lock_file"
echo "Released stale lock: $(basename "$lock_file")"
fi
done
}
check_lock() {
local issue_ref="$1"
local lock_file="$LOCKS_DIR/${issue_ref//[^a-zA-Z0-9._-]/_}.lock"
if [ -f "$lock_file" ]; then
local lock_pid=$(cat "$lock_file" 2>/dev/null | cut -d: -f1)
local lock_session=$(cat "$lock_file" 2>/dev/null | cut -d: -f2)
if [ -n "$lock_pid" ] && ! kill -0 "$lock_pid" 2>/dev/null; then
echo "Stale lock detected, removing..."
rm -f "$lock_file"
return 1
fi
echo "Locked by session $lock_session (PID $lock_pid)"
return 0
fi
return 1
}
usage() { usage() {
cat << 'EOF' cat << 'EOF'
kugetsu - OpenCode Session Manager (Issue-Driven) kugetsu - OpenCode Session Manager (Issue-Driven)
@@ -563,8 +647,10 @@ cmd_delegate() {
mkdir -p "$LOGS_DIR" mkdir -p "$LOGS_DIR"
local log_file="$LOGS_DIR/delegate-$(date +%s).log" local log_file="$LOGS_DIR/delegate-$(date +%s).log"
local temp_dir="${KUGETSU_TEMP_DIR:-$HOME/.local/share/opencode/tool-output}"
mkdir -p "$ENV_DIR" mkdir -p "$ENV_DIR"
local env_sh="set -a; " local env_sh="set -a; export KUGETSU_TEMP_DIR='$temp_dir'; "
if [ -f "$ENV_DIR/pm-agent.env" ]; then if [ -f "$ENV_DIR/pm-agent.env" ]; then
env_sh="${env_sh}source '$ENV_DIR/pm-agent.env'; " env_sh="${env_sh}source '$ENV_DIR/pm-agent.env'; "
elif [ -f "$ENV_DIR/default.env" ]; then elif [ -f "$ENV_DIR/default.env" ]; then
@@ -593,6 +679,116 @@ cmd_logs() {
done done
} }
init_queue() {
if [ ! -f "$QUEUE_FILE" ]; then
cat > "$QUEUE_FILE" << 'EOF'
{
"dev_followups": [],
"user_interrupts": [],
"background": []
}
EOF
fi
}
cmd_queue() {
local action="${1:-}"
init_queue
case "$action" in
""|"list")
local total=0
echo "Queue status:"
for tier in dev_followups user_interrupts background; do
local count=$(python3 -c "import json; d=json.load(open('$QUEUE_FILE')); print(len(d.get('$tier', [])))" 2>/dev/null || echo 0)
total=$((total + count))
if [ "$count" -eq 0 ]; then
echo " $tier (0): (empty)"
else
echo " $tier ($count):"
python3 -c "import json, sys; d=json.load(open('$QUEUE_FILE')); [print(f' [{t[\"id\"]}] {t[\"message\"][:60]}') for t in d.get('$tier', [])]" 2>/dev/null || echo " (error reading)"
fi
done
echo "Total queued: $total"
;;
"enqueue")
local tier="${2:-}"
local message="${3:-}"
if [ -z "$tier" ] || [ -z "$message" ]; then
echo "Usage: kugetsu queue enqueue <tier> <message>" >&2
echo " tier: dev_followups, user_interrupts, or background" >&2
exit 1
fi
if [[ ! "$tier" =~ ^(dev_followups|user_interrupts|background)$ ]]; then
echo "Error: Invalid tier '$tier'" >&2
echo " Valid tiers: dev_followups, user_interrupts, background" >&2
exit 1
fi
local id="qe-$(date +%s)-$$"
python3 << EOF
import json
with open('$QUEUE_FILE', 'r') as f:
d = json.load(f)
d.setdefault('$tier', []).append({
'id': '$id',
'message': '$message',
'created': '$(date -Iseconds)'
})
with open('$QUEUE_FILE', 'w') as f:
json.dump(d, f, indent=2)
print('Enqueued to $tier: [$id] $message')
EOF
;;
"dequeue")
local tier="${2:-}"
local result=$(python3 << EOF
import json
with open('$QUEUE_FILE', 'r') as f:
d = json.load(f)
tiers = ['dev_followups', 'user_interrupts', 'background'] if not '$tier' else ['$tier']
for t in tiers:
if d.get(t) and len(d[t]) > 0:
task = d[t].pop(0)
with open('$QUEUE_FILE', 'w') as f:
json.dump(d, f, indent=2)
print(f'{t}|{task["id"]}|{task["message"]}')
break
else:
print('Queue empty')
EOF
)
if [ "$result" = "Queue empty" ]; then
echo "$result"
exit 1
fi
echo "$result"
;;
"clear")
cat > "$QUEUE_FILE" << 'EOF'
{
"dev_followups": [],
"user_interrupts": [],
"background": []
}
EOF
echo "Queue cleared"
;;
*)
echo "Usage: kugetsu queue <list|enqueue|dequeue|clear>" >&2
echo "" >&2
echo "Commands:" >&2
echo " list Show queue status" >&2
echo " enqueue <tier> <msg> Add task to queue" >&2
echo " dequeue [tier] Remove and return next task" >&2
echo " clear Clear all queued tasks" >&2
echo "" >&2
echo "Tiers: dev_followups, user_interrupts, background" >&2
exit 1
;;
esac
}
cmd_env() { cmd_env() {
local action="${1:-}" local action="${1:-}"
local agent_type="${2:-}" local agent_type="${2:-}"
@@ -695,12 +891,16 @@ cmd_env() {
cmd_doctor() { cmd_doctor() {
local fix=false local fix=false
local fix_permissions=false
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--fix) --fix)
fix=true fix=true
;; ;;
--fix-permissions)
fix_permissions=true
;;
*) *)
;; ;;
esac esac
@@ -798,6 +998,52 @@ cmd_doctor() {
fi fi
fi fi
fi fi
if [ "$fix_permissions" = true ]; then
echo ""
echo "Fixing session permissions..."
fix_session_permissions
fi
}
fix_session_permissions() {
local opencode_db="${OPENCODE_DB:-$HOME/.opencode/opencode.db}"
if [ ! -f "$opencode_db" ]; then
echo "[ERROR] opencode database not found: $opencode_db"
return 1
fi
local base_session_id=$(get_base_session_id)
local pm_agent_session_id=$(get_pm_agent_session_id)
local PERMISSION_JSON='[{"permission":"question","pattern":"*","action":"deny"},{"permission":"plan_enter","pattern":"*","action":"deny"},{"permission":"plan_exit","pattern":"*","action":"deny"},{"permission":"external_directory","pattern":"*","action":"allow"}]'
if [ -n "$base_session_id" ] && [ "$base_session_id" != "null" ]; then
echo "Updating base session permissions: $base_session_id"
python3 -c "
import sqlite3
conn = sqlite3.connect('$opencode_db')
cursor = conn.cursor()
cursor.execute(\"UPDATE session SET permission = ? WHERE id = ?\", ('$PERMISSION_JSON', '$base_session_id'))
conn.commit()
print('[OK] Base session permissions updated')
"
fi
if [ -n "$pm_agent_session_id" ] && [ "$pm_agent_session_id" != "null" ] && [ "$pm_agent_session_id" != "None" ]; then
echo "Updating PM agent session permissions: $pm_agent_session_id"
python3 -c "
import sqlite3
conn = sqlite3.connect('$opencode_db')
cursor = conn.cursor()
cursor.execute(\"UPDATE session SET permission = ? WHERE id = ?\", ('$PERMISSION_JSON', '$pm_agent_session_id'))
conn.commit()
print('[OK] PM agent session permissions updated')
"
fi
echo "Session permissions fix complete"
} }
DEBUG_MODE=false DEBUG_MODE=false
@@ -1052,6 +1298,8 @@ EOF
echo "Initialization complete!" echo "Initialization complete!"
echo "- Base session: $new_session_id" echo "- Base session: $new_session_id"
echo "- PM agent: ${new_pm_session_id:-created by hermes}" echo "- PM agent: ${new_pm_session_id:-created by hermes}"
fix_session_permissions
} }
cmd_start() { cmd_start() {
@@ -1097,6 +1345,14 @@ cmd_start() {
fi fi
local worktree_path=$(issue_ref_to_worktree_path "$issue_ref") local worktree_path=$(issue_ref_to_worktree_path "$issue_ref")
trap 'release_lock "$issue_ref" 2>/dev/null; exit' EXIT INT TERM
if ! acquire_lock "$issue_ref" "$base_session_id"; then
echo "Error: Could not acquire lock for '$issue_ref'" >&2
exit 1
fi
create_worktree "$issue_ref" create_worktree "$issue_ref"
local session_file="$(issue_ref_to_filename "$issue_ref").json" local session_file="$(issue_ref_to_filename "$issue_ref").json"
@@ -1163,6 +1419,8 @@ cmd_start() {
echo "Session started for '$issue_ref': $new_session_id" echo "Session started for '$issue_ref': $new_session_id"
echo "Worktree: $worktree_path" echo "Worktree: $worktree_path"
release_lock "$issue_ref"
} }
cmd_continue() { cmd_continue() {
@@ -1416,6 +1674,7 @@ cmd_destroy() {
if [ "$force" = true ]; then if [ "$force" = true ]; then
remove_worktree_for_issue "$target" remove_worktree_for_issue "$target"
release_lock "$target" 2>/dev/null || true
rm -f "$session_path" rm -f "$session_path"
remove_issue_from_index "$target" remove_issue_from_index "$target"
echo "Session for '$target' destroyed" echo "Session for '$target' destroyed"
@@ -1425,6 +1684,7 @@ cmd_destroy() {
read reply read reply
if [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then if [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then
remove_worktree_for_issue "$target" remove_worktree_for_issue "$target"
release_lock "$target" 2>/dev/null || true
rm -f "$session_path" rm -f "$session_path"
remove_issue_from_index "$target" remove_issue_from_index "$target"
echo "Session for '$target' destroyed" echo "Session for '$target' destroyed"
@@ -1469,6 +1729,9 @@ main() {
server) server)
cmd_server "$@" cmd_server "$@"
;; ;;
queue)
cmd_queue "$@"
;;
env) env)
cmd_env "$@" cmd_env "$@"
;; ;;

View File

@@ -634,9 +634,70 @@ else
fi fi
echo "" echo ""
# Test E7: KUGETSU_TEMP_DIR is exported in cmd_delegate
echo "--- Test: KUGETSU_TEMP_DIR export in cmd_delegate ---"
if grep -q "KUGETSU_TEMP_DIR" "$KUGETSU" && grep -q "export KUGETSU_TEMP_DIR" "$KUGETSU"; then
pass "KUGETSU_TEMP_DIR is exported to delegated agents"
else
fail "KUGETSU_TEMP_DIR not found in cmd_delegate export"
fi
echo ""
# Cleanup env files # Cleanup env files
rm -rf ~/.kugetsu/env 2>/dev/null || true rm -rf ~/.kugetsu/env 2>/dev/null || true
# Test E7: fix_session_permissions function exists
echo "--- Test: fix_session_permissions function exists ---"
if grep -q "fix_session_permissions()" "$KUGETSU"; then
pass "fix_session_permissions function exists"
else
fail "fix_session_permissions function not found"
fi
echo ""
# Test E8: cmd_doctor --fix-permissions flag is recognized
echo "--- Test: cmd_doctor --fix-permissions flag ---"
OUTPUT=$($KUGETSU doctor --fix-permissions 2>&1 || true)
if echo "$OUTPUT" | grep -q -E "(Fixing session permissions|Session permissions fix complete|opencode database not found)"; then
pass "cmd_doctor --fix-permissions flag is recognized"
else
fail "cmd_doctor --fix-permissions not recognized: $OUTPUT"
fi
echo ""
# Test E9: fix_session_permissions has valid permission JSON
echo "--- Test: fix_session_permissions has valid permission JSON ---"
PERMISSION_JSON='[{"permission":"question","pattern":"*","action":"deny"},{"permission":"plan_enter","pattern":"*","action":"deny"},{"permission":"plan_exit","pattern":"*","action":"deny"},{"permission":"external_directory","pattern":"*","action":"allow"}]'
if python3 -c "import json; json.loads('$PERMISSION_JSON')" 2>/dev/null; then
pass "fix_session_permissions has valid permission JSON"
else
fail "fix_session_permissions permission JSON is invalid"
fi
echo ""
# Test E10: fix_session_permissions SQL UPDATE syntax is valid
echo "--- Test: fix_session_permissions SQL UPDATE syntax ---"
if python3 -c "
import sqlite3
conn = sqlite3.connect(':memory:')
cursor = conn.cursor()
cursor.execute('CREATE TABLE session (id TEXT, permission TEXT)')
cursor.execute('INSERT INTO session (id, permission) VALUES (?, ?)', ('test_id', 'original'))
cursor.execute('UPDATE session SET permission = ? WHERE id = ?', ('$PERMISSION_JSON', 'test_id'))
conn.commit()
cursor.execute('SELECT permission FROM session WHERE id = ?', ('test_id',))
result = cursor.fetchone()
if result and 'external_directory' in result[0]:
print('OK')
else:
print('FAIL')
" 2>/dev/null | grep -q OK; then
pass "fix_session_permissions SQL UPDATE syntax is valid"
else
fail "fix_session_permissions SQL UPDATE syntax failed"
fi
echo ""
# Cleanup # Cleanup
cleanup cleanup