Skip to content
Back to skills

Debug Task

ASecurity

Debug autonomous /task execution — query task_runs state, phase context/costs, trace tool decisions in audit.jsonl, and check app logs for errors. Use when a task fails, gets stuck, escalates, or behaves unexpectedly.

  • 5 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 23, 2026
ai-agentspythongobashsqldebuggingsecurity

Works with

  • terminal

Security analysis

A100/100

Scanned October 1, 2026

npx -y skills add vmehera123/leashd --skill debug-task --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Debug Task?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Debug Task
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/vmehera123-debug-task/badge)](https://www.skillsdirectory.com/skills/vmehera123-debug-task)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: debug-task
description: Debug autonomous /task execution — query task_runs state, phase context/costs, trace tool decisions in audit.jsonl, and check app logs for errors. Use when a task fails, gets stuck, escalates, or behaves unexpectedly.
argument-hint: "<task_id>"
allowed-tools:
  - Bash
  - Read
  - Grep
  - Glob
  - AskUserQuestion
---

# Debugging Autonomous Tasks

## Data sources

| Surface | Location | Content |
|---------|----------|---------|
| Task runs | `~/.leashd/sessions.db` → `task_runs` table | Phase state, outcome, costs, context, pipeline, timestamps (24 columns) |
| Sessions | `~/.leashd/sessions.db` → `sessions` table | `mode`, `task_run_id` link back to task_runs |
| Audit log | `<approved_directories[0]>/.leashd/audit.jsonl` (pinned/centralized) | Tool-gating decisions (allow/deny/require_approval), all sessions |
| App logs | `{working_directory}/.leashd/logs/app.log` | Structured events: `task_phase_changed`, `task_phase_error`, `task_terminal`, etc. |
| Messages | `~/.leashd/messages.db` (centralized) | Agent messages during the task's session |

> **audit.jsonl is centralized, not per-working-directory.** Since v0.9.0 it is *pinned* to `approved_directories[0]` (the first entry in `~/.leashd/config.yaml`) — NOT `{working_directory}/.leashd/audit.jsonl`, which will look stale for any task that ran outside that dir. The audit queries below resolve the pinned path automatically.

`$ARGUMENTS` is the task_id — full 16-char hex or first 8 chars from `/tasks` output.

> Current tasks run the linear `implement → verify → [review]` pipeline (`plugins/builtin/task_orchestrator.py`). Older rows in `task_runs` may carry v2/v3/v4 phases (`explore`, `plan`, `retry`, `pr`, …); check `phase_pipeline` in Step 1 to tell which.

## Quick diagnosis (run all 5 steps)

### Step 1: Load task state

Query `task_runs` by run_id. Try exact match first, then prefix match.

```bash
uv run python -c "
import sqlite3, pathlib, sys, json
db = pathlib.Path.home() / '.leashd' / 'sessions.db'
if not db.exists():
    print('ERROR: sessions.db not found at', db)
    sys.exit(1)
conn = sqlite3.connect(str(db))
conn.row_factory = sqlite3.Row
tid = '$ARGUMENTS'.strip()
if not tid:
    print('No task_id provided. Listing recent tasks...')
    for r in conn.execute('SELECT run_id, phase, outcome, task, created_at, total_cost FROM task_runs ORDER BY created_at DESC LIMIT 20'):
        d = dict(r)
        d['task'] = d['task'][:80]
        print(d)
    sys.exit(0)
# Exact match
row = conn.execute('SELECT * FROM task_runs WHERE run_id = ?', (tid,)).fetchone()
if not row:
    # Prefix match
    row = conn.execute('SELECT * FROM task_runs WHERE run_id LIKE ?', (tid + '%',)).fetchone()
if not row:
    print(f'No task found matching: {tid}')
    sys.exit(1)
d = dict(row)
# Pretty-print key fields
for k in ['run_id','phase','previous_phase','outcome','error_message','retry_count','max_retries',
          'created_at','started_at','phase_started_at','completed_at','last_updated',
          'total_cost','working_directory','session_id','user_id','chat_id','task']:
    print(f'{k:>20}: {d.get(k)}')
print(f\"{'phase_pipeline':>20}: {json.loads(d.get('phase_pipeline','[]'))}\")
print(f\"{'phase_costs':>20}: {json.loads(d.get('phase_costs','{}'))}\")
"
```

### Step 2: Check phase context

Parse the JSON `phase_context` dict showing accumulated output per phase.

```bash
uv run python -c "
import sqlite3, pathlib, json, sys, textwrap
db = pathlib.Path.home() / '.leashd' / 'sessions.db'
conn = sqlite3.connect(str(db))
conn.row_factory = sqlite3.Row
tid = '$ARGUMENTS'.strip()
row = conn.execute('SELECT * FROM task_runs WHERE run_id = ? OR run_id LIKE ?', (tid, tid + '%')).fetchone()
if not row:
    print(f'No task found matching: {tid}')
    sys.exit(1)
ctx = json.loads(row['phase_context'] or '{}')
if not ctx:
    print('phase_context is empty')
    sys.exit(0)
for key, val in ctx.items():
    preview = textwrap.shorten(str(val), width=500, placeholder='...')
    print(f'--- {key} ---')
    print(preview)
    print()
"
```

### Step 3: Check app logs for task events

Grep for task lifecycle events filtered by run_id.

```bash
TASK_ID="$ARGUMENTS"
# Resolve full run_id first
FULL_ID=$(uv run python -c "
import sqlite3, pathlib
db = pathlib.Path.home() / '.leashd' / 'sessions.db'
conn = sqlite3.connect(str(db))
row = conn.execute('SELECT run_id, working_directory, session_id FROM task_runs WHERE run_id = ? OR run_id LIKE ?', ('$ARGUMENTS', '$ARGUMENTS' + '%')).fetchone()
if row: print(row[0], row[1], row[2])
")
RUN_ID=$(echo "$FULL_ID" | awk '{print $1}')
WORK_DIR=$(echo "$FULL_ID" | awk '{print $2}')
SESSION_ID=$(echo "$FULL_ID" | awk '{print $3}')

if [ -z "$RUN_ID" ]; then echo "Task not found"; exit 1; fi
echo "=== Task events for $RUN_ID in $WORK_DIR ==="

LOG_FILE="$WORK_DIR/.leashd/logs/app.log"
if [ ! -f "$LOG_FILE" ]; then echo "No app.log at $LOG_FILE"; exit 0; fi

grep -E "task_phase_changed|task_terminal|task_created|task_cancelled|task_phase_error|task_stale_cleaned_on_start" "$LOG_FILE" | grep "$RUN_ID" | tail -30
```

### Step 4: Check for errors in app logs

Grep for error-level log entries filtered by the task's session_id.

```bash
FULL_ID=$(uv run python -c "
import sqlite3, pathlib
db = pathlib.Path.home() / '.leashd' / 'sessions.db'
conn = sqlite3.connect(str(db))
row = conn.execute('SELECT session_id, working_directory FROM task_runs WHERE run_id = ? OR run_id LIKE ?', ('$ARGUMENTS', '$ARGUMENTS' + '%')).fetchone()
if row: print(row[0], row[1])
")
SESSION_ID=$(echo "$FULL_ID" | awk '{print $1}')
WORK_DIR=$(echo "$FULL_ID" | awk '{print $2}')

if [ -z "$SESSION_ID" ]; then echo "Task not found"; exit 1; fi
LOG_FILE="$WORK_DIR/.leashd/logs/app.log"
if [ ! -f "$LOG_FILE" ]; then echo "No app.log at $LOG_FILE"; exit 0; fi

echo "=== Errors for session $SESSION_ID ==="
grep '"level": "error"' "$LOG_FILE" | grep "$SESSION_ID" | tail -20
```

### Step 5: Check tool denials

Count denied tools in audit.jsonl for the task's session.

```bash
FULL_ID=$(uv run python -c "
import sqlite3, pathlib
db = pathlib.Path.home() / '.leashd' / 'sessions.db'
conn = sqlite3.connect(str(db))
row = conn.execute('SELECT session_id, working_directory FROM task_runs WHERE run_id = ? OR run_id LIKE ?', ('$ARGUMENTS', '$ARGUMENTS' + '%')).fetchone()
if row: print(row[0], row[1])
")
SESSION_ID=$(echo "$FULL_ID" | awk '{print $1}')
WORK_DIR=$(echo "$FULL_ID" | awk '{print $2}')

if [ -z "$SESSION_ID" ]; then echo "Task not found"; exit 1; fi
# audit.jsonl is pinned to approved_directories[0], not WORK_DIR.
AUDIT_FILE=$(uv run python -c "
import pathlib, yaml
cfg = yaml.safe_load((pathlib.Path.home() / '.leashd' / 'config.yaml').read_text())
print(pathlib.Path(cfg['approved_directories'][0]) / '.leashd' / 'audit.jsonl')
")
if [ ! -f "$AUDIT_FILE" ]; then echo "No audit.jsonl at $AUDIT_FILE"; exit 0; fi

echo "=== Tool denials for session $SESSION_ID ==="
grep "$SESSION_ID" "$AUDIT_FILE" | grep '"decision": "deny"' | uv run python -c "
import json, sys
from collections import Counter
tools = Counter()
for line in sys.stdin:
    e = json.loads(line)
    tools[e.get('tool_name', '?')] += 1
if not tools:
    print('No denied tools')
else:
    for tool, count in tools.most_common():
        print(f'  {count:>3}x  {tool}')
"
```

## Deep dive sections

### Phase timing analysis

Duration per phase from timestamps. Flags staleness.

```bash
uv run python -c "
import sqlite3, pathlib, json, sys
from datetime import datetime, timezone
db = pathlib.Path.home() / '.leashd' / 'sessions.db'
conn = sqlite3.connect(str(db))
conn.row_factory = sqlite3.Row
tid = '$ARGUMENTS'.strip()
row = conn.execute('SELECT * FROM task_runs WHERE run_id = ? OR run_id LIKE ?', (tid, tid + '%')).fetchone()
if not row:
    print(f'No task found matching: {tid}')
    sys.exit(1)
d = dict(row)
pipeline = json.loads(d.get('phase_pipeline', '[]'))
costs = json.loads(d.get('phase_costs', '{}'))
created = d['created_at']
started = d.get('started_at')
completed = d.get('completed_at')
last_updated = d.get('last_updated')
phase_started = d.get('phase_started_at')

def parse_dt(s):
    if not s: return None
    return datetime.fromisoformat(s).replace(tzinfo=timezone.utc)

now = datetime.now(timezone.utc)
print(f'Pipeline: {pipeline}')
print(f'Current phase: {d[\"phase\"]}')
print(f'Created: {created}')
print(f'Started: {started or \"(not started)\"}')
print(f'Completed: {completed or \"(in progress)\"}')
print()
# Total duration
c = parse_dt(created)
e = parse_dt(completed) or now
total_secs = (e - c).total_seconds()
print(f'Total elapsed: {total_secs/3600:.2f}h ({total_secs:.0f}s)')
# Phase costs
if costs:
    print()
    print('Phase costs:')
    for phase, cost in sorted(costs.items()):
        print(f'  {phase:>20}: \${cost:.4f}')
    print(f'  {\"TOTAL\":>20}: \${d[\"total_cost\"]:.4f}')
# Staleness check
lu = parse_dt(last_updated)
if lu:
    age_hours = (now - lu).total_seconds() / 3600
    if age_hours > 24:
        print(f'\nWARNING: Last updated {age_hours:.1f}h ago — likely stale!')
    elif age_hours > 1:
        print(f'\nNote: Last updated {age_hours:.1f}h ago')
# Current phase duration
ps = parse_dt(phase_started)
if ps and not completed:
    phase_secs = (now - ps).total_seconds()
    print(f'Current phase running for: {phase_secs/60:.1f}min')
"
```

### Agent messages during task

Query messages.db for the task's session.

```bash
uv run python -c "
import sqlite3, pathlib, sys
db = pathlib.Path.home() / '.leashd' / 'sessions.db'
conn = sqlite3.connect(str(db))
conn.row_factory = sqlite3.Row
tid = '$ARGUMENTS'.strip()
row = conn.execute('SELECT session_id, working_directory FROM task_runs WHERE run_id = ? OR run_id LIKE ?', (tid, tid + '%')).fetchone()
if not row:
    print(f'No task found matching: {tid}')
    sys.exit(1)
session_id = row['session_id']
work_dir = row['working_directory']
msg_db = pathlib.Path.home() / '.leashd' / 'messages.db'
if not msg_db.exists():
    # Fallback for older per-project layout
    msg_db = pathlib.Path(work_dir) / '.leashd' / 'messages.db'
if not msg_db.exists():
    print(f'No messages.db at ~/.leashd/messages.db or {work_dir}/.leashd/messages.db')
    sys.exit(0)
mconn = sqlite3.connect(str(msg_db))
mconn.row_factory = sqlite3.Row
rows = mconn.execute(
    'SELECT id, role, substr(content, 1, 150) AS preview, cost, duration_ms, created_at FROM messages WHERE session_id = ? ORDER BY created_at ASC, id ASC',
    (session_id,)
).fetchall()
if not rows:
    print(f'No messages for session {session_id}')
    sys.exit(0)
print(f'=== {len(rows)} messages for session {session_id} ===')
for r in rows:
    d = dict(r)
    cost_str = f' \${d[\"cost\"]:.4f}' if d['cost'] else ''
    dur_str = f' {d[\"duration_ms\"]}ms' if d['duration_ms'] else ''
    print(f'{d[\"id\"]:>4} {d[\"role\"]:>9} {d[\"created_at\"][:19]}{cost_str}{dur_str}  {d[\"preview\"]}')
"
```

### Full audit trail for session

Chronological tool decisions from audit.jsonl.

```bash
FULL_ID=$(uv run python -c "
import sqlite3, pathlib
db = pathlib.Path.home() / '.leashd' / 'sessions.db'
conn = sqlite3.connect(str(db))
row = conn.execute('SELECT session_id, working_directory FROM task_runs WHERE run_id = ? OR run_id LIKE ?', ('$ARGUMENTS', '$ARGUMENTS' + '%')).fetchone()
if row: print(row[0], row[1])
")
SESSION_ID=$(echo "$FULL_ID" | awk '{print $1}')
WORK_DIR=$(echo "$FULL_ID" | awk '{print $2}')

if [ -z "$SESSION_ID" ]; then echo "Task not found"; exit 1; fi
# audit.jsonl is pinned to approved_directories[0], not WORK_DIR.
AUDIT_FILE=$(uv run python -c "
import pathlib, yaml
cfg = yaml.safe_load((pathlib.Path.home() / '.leashd' / 'config.yaml').read_text())
print(pathlib.Path(cfg['approved_directories'][0]) / '.leashd' / 'audit.jsonl')
")
if [ ! -f "$AUDIT_FILE" ]; then echo "No audit.jsonl at $AUDIT_FILE"; exit 0; fi

echo "=== Audit trail for session $SESSION_ID ==="
grep "$SESSION_ID" "$AUDIT_FILE" | uv run python -c "
import json, sys
for line in sys.stdin:
    e = json.loads(line)
    evt = e.get('event', '?')
    ts = e.get('timestamp', '?')[:19]
    if evt == 'tool_attempt':
        print(f'{ts}  {e.get(\"decision\",\"?\"):>16}  {e.get(\"risk_level\",\"?\"):>7}  {e[\"tool_name\"]}  rule={e.get(\"matched_rule\")}')
    elif evt == 'approval':
        print(f'{ts}  {\"APPROVED\" if e.get(\"approved\") else \"REJECTED\":>16}          {e.get(\"tool_name\",\"?\")}  by={e.get(\"user_id\",\"?\")}')
    elif evt == 'security_violation':
        print(f'{ts}  VIOLATION          {e.get(\"tool_name\",\"?\")}  reason={e.get(\"reason\",\"?\")}')
    else:
        print(f'{ts}  {evt}')
"
```

### Related session state

Check the session linked to this task for mode/state mismatches.

```bash
uv run python -c "
import sqlite3, pathlib, sys
db = pathlib.Path.home() / '.leashd' / 'sessions.db'
conn = sqlite3.connect(str(db))
conn.row_factory = sqlite3.Row
tid = '$ARGUMENTS'.strip()
task_row = conn.execute('SELECT * FROM task_runs WHERE run_id = ? OR run_id LIKE ?', (tid, tid + '%')).fetchone()
if not task_row:
    print(f'No task found matching: {tid}')
    sys.exit(1)
task = dict(task_row)
# Find the session that matches user_id + chat_id
sess_row = conn.execute(
    'SELECT * FROM sessions WHERE user_id = ? AND chat_id = ?',
    (task['user_id'], task['chat_id'])
).fetchone()
if not sess_row:
    print('No session found for this task\\'s user_id + chat_id')
    sys.exit(0)
sess = dict(sess_row)
print('=== Session state ===')
for k in ['session_id','mode','task_run_id','working_directory','is_active','last_used','total_cost','message_count']:
    print(f'  {k:>20}: {sess.get(k)}')
# Check mismatches
issues = []
if sess.get('task_run_id') and sess['task_run_id'] != task['run_id']:
    issues.append(f'task_run_id MISMATCH: session has {sess[\"task_run_id\"]}, task is {task[\"run_id\"]}')
if task['phase'] not in ('completed','failed','escalated','cancelled') and sess.get('mode') == 'default':
    issues.append(f'Session mode is default but task is still active (phase={task[\"phase\"]})')
if sess.get('working_directory') != task.get('working_directory'):
    issues.append(f'Working directory mismatch: session={sess.get(\"working_directory\")}, task={task.get(\"working_directory\")}')
if issues:
    print()
    print('ISSUES DETECTED:')
    for i in issues:
        print(f'  - {i}')
else:
    print()
    print('No mismatches detected')
"
```

### Test output analysis

Replicate `detect_test_failure` heuristics from `core/test_output.py` on the stored test output.

```bash
uv run python -c "
import sqlite3, pathlib, json, sys
db = pathlib.Path.home() / '.leashd' / 'sessions.db'
conn = sqlite3.connect(str(db))
conn.row_factory = sqlite3.Row
tid = '$ARGUMENTS'.strip()
row = conn.execute('SELECT phase_context FROM task_runs WHERE run_id = ? OR run_id LIKE ?', (tid, tid + '%')).fetchone()
if not row:
    print(f'No task found matching: {tid}')
    sys.exit(1)
ctx = json.loads(row['phase_context'] or '{}')
test_output = ctx.get('test_output', '')
if not test_output:
    print('No test_output in phase_context')
    sys.exit(0)

FAILURE_INDICATORS = [
    'test failed', 'tests failed', 'traceback (most recent call last)',
    'assertionerror', 'failed:', 'fail:', 'pytest: ', 'exit code 1',
    'exit code 2', 'build failed', 'error:', 'failures',
]
SUCCESS_INDICATORS = [
    'all tests pass', 'tests passed', 'all passing',
    '0 failed', 'build succeeded', 'no errors',
]
lower = test_output.lower()
found_failures = [ind for ind in FAILURE_INDICATORS if ind in lower]
found_success = [ind for ind in SUCCESS_INDICATORS if ind in lower]
has_failure = bool(found_failures)
has_success = bool(found_success)
detected = has_failure and not (has_success and not has_failure)

print(f'Test output length: {len(test_output)} chars')
print(f'Failure indicators found: {found_failures or \"(none)\"}')
print(f'Success indicators found: {found_success or \"(none)\"}')
print(f'detect_test_failure result: {detected}')
print()
print('--- Last 500 chars of test output ---')
print(test_output[-500:])
"
```

## Common failure patterns

| # | Pattern | How to identify | Likely cause |
|---|---------|-----------------|--------------|
| 1 | **Stale task** | `outcome=timeout`, `last_updated` 24h+ ago | Daemon restarted without recovery, or agent hung |
| 2 | **Escalated after max retries** | `phase=escalated`, `retry_count >= max_retries` | Test failures exhausted retries — check `test_output` in phase_context |
| 3 | **Failed with runtime error** | `phase=failed`, `outcome=error`, `error_message` set | Exception in `_execute_phase` — check app.log for `task_phase_error` |
| 4 | **Tool denial blocking progress** | Repeated denials in audit.jsonl for same tool | Policy hard-deny overrides auto-approve; check `autonomous.yaml` rules |
| 5 | **Phase stuck** | Non-terminal phase, no `session.completed` event in logs | Agent session didn't complete — check for `request_failed` or hanging agent |
| 6 | **Missing working directory** | `error_message` mentions directory | Working directory deleted or inaccessible after task started |
| 7 | **Session mode mismatch** | Session `task_run_id` differs from task `run_id` | Another task was submitted to same chat, or session was reset mid-task |
| 8 | **Unexpected pipeline** | `phase_pipeline` is missing `review` or has one you didn't ask for | The pipeline comes from the task profile: defaults, then `.leashd/task-config.yaml`, then `/task --phases` (merged in `TaskOrchestrator._register_task_profile`) |
| 9 | **Unexpected cancellation** | `phase=cancelled`, `error_message=User cancelled` | User sent `/cancel`, `/stop`, or `/clear` during execution |

## List all tasks (fallback when no task_id provided)

```bash
uv run python -c "
import sqlite3, pathlib, json
db = pathlib.Path.home() / '.leashd' / 'sessions.db'
conn = sqlite3.connect(str(db))
conn.row_factory = sqlite3.Row
rows = conn.execute('SELECT run_id, phase, outcome, retry_count, total_cost, created_at, last_updated, task FROM task_runs ORDER BY created_at DESC LIMIT 20').fetchall()
if not rows:
    print('No tasks found in sessions.db')
else:
    phase_emoji = {'pending':'⏳','plan':'📝','implement':'🔨','test':'🧪','retry':'🔄','pr':'📦','completed':'✅','failed':'❌','escalated':'⚠️','cancelled':'🛑','explore':'🔍','validate_plan':'📋','validate_spec':'📋','spec':'📄','fix':'🛠️','verify':'✔️','review':'👀'}
    for r in rows:
        d = dict(r)
        emoji = phase_emoji.get(d['phase'], '❓')
        cost = f' \${d[\"total_cost\"]:.4f}' if d['total_cost'] else ''
        outcome = f' [{d[\"outcome\"]}]' if d['outcome'] else ''
        retries = f' retries={d[\"retry_count\"]}' if d['retry_count'] else ''
        preview = d['task'][:70] + ('...' if len(d['task']) > 70 else '')
        print(f'{emoji} {d[\"run_id\"][:8]}  {d[\"phase\"]:>15}{outcome}{cost}{retries}  {d[\"created_at\"][:16]}')
        print(f'   {preview}')
        print()
"
```

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.