diff --git a/skill/SKILL.md b/skill/SKILL.md new file mode 100644 index 0000000..b882da4 --- /dev/null +++ b/skill/SKILL.md @@ -0,0 +1,115 @@ +--- +name: work-queue +description: "Work queue skill for TheLab agents. Submit, dispatch, track, and complete work items via the Work Queue API. Embeds TheLab dispatch opinion: one in_progress per agent, stale detection, automatic blocking of timed-out items." +--- + +# Work Queue Skill + + Thin wrapper around the Work Queue API with embedded dispatch opinion. + +## Setup + +Set the base URL: +```bash +export WORK_QUEUE_API_URL=https://api.example.com # replace with actual API URL +``` + +The skill reads `WORK_QUEUE_API_URL` from env. + +## Core Commands + +### `wq add` + +Submit a new work item (status=queued). + +```bash +wq add [--agent ] [--project-id ] [--priority 1-5>] [--payload ] +``` + +Example: +```bash +wq add code_review "Review PR #3 in shopping-list-api" --agent steve-w --priority 2 --payload '{"pr":3,"repo":"shopping-list-api"}' +``` + +### `wq dispatch` + +Dispatch a queued item to an agent (moves queued→dispatched→in_progress atomically). + +```bash +wq dispatch +``` + +Fails if agent already has an in_progress item. + +### `wq update` + +Update status, outcome, or notes on a work item. + +```bash +wq update [--status ] [--outcome ] [--notes ] +``` + +Valid status transitions: +- dispatched → in_progress (agent picked it up) +- in_progress → blocked (waiting on something) +- in_progress → completed (done) +- in_progress → failed (unrecoverable error) +- queued → cancelled +- dispatched → cancelled + +### `wq list` + +List work items, optionally filtered. + +```bash +wq list [--status ] [--agent ] [--project-id ] +``` + +### `wq get` + +Get a single work item by ID. + +```bash +wq get +``` + +### `wq my-queue` + +Short-cut: list items assigned to a given agent with status=dispatched (what Steve should poll). + +```bash +wq my-queue +``` + +--- + +## Dispatch Opinion + +These rules are enforced automatically by the skill: + +1. **One in_progress per agent** — dispatch fails if target agent is already in_progress +2. **Stale detection** — on every heartbeat, `wq stale-check` is called; items in_progress >30min are automatically marked blocked +3. **Terminal states require outcome** — moving to completed/failed/cancelled requires outcome field +4. **Cancelled only from queued/dispatched** — cannot cancel something already in_progress + +--- + +## Stale Check (for heartbeat) + +```bash +wq stale-check [--timeout-minutes 30] +``` + +Finds all in_progress items older than timeout, marks them blocked, prints a summary line per item. + +--- + +## Integration with Gitea Watcher + +The Gitea watcher (`gitea_cron/check.sh`) outputs `dispatch:` lines. On heartbeat, parse those lines and for each: + +```bash +wq add --agent steve-w --payload '' +``` + +The watcher itself does NOT call the API — it just emits dispatch lines. Marcus's heartbeat parses them, creates real work items via `wq add`, then dispatches via `wq dispatch`. diff --git a/skill/bin/wq b/skill/bin/wq new file mode 100755 index 0000000..7a2bf29 --- /dev/null +++ b/skill/bin/wq @@ -0,0 +1,47 @@ +#!/bin/bash +# wq — Work Queue CLI wrapper +# Usage: wq [options] + +set -e + +API_URL="${WORK_QUEUE_API_URL:-}" +if [[ -z "$API_URL" ]]; then + if [[ -f ~/.config/work_queue_api_url ]]; then + API_URL=$(cat ~/.config/work_queue_api_url) + else + echo "Error: WORK_QUEUE_API_URL not set and no ~/.config/work_queue_api_url" >&2 + exit 1 + fi +fi + +CMD="$1" +shift || { echo "Usage: wq [args]" >&2; exit 1; } + +case "$CMD" in + add) + wq_add "$@" + ;; + dispatch) + wq_dispatch "$@" + ;; + update) + wq_update "$@" + ;; + list) + wq_list "$@" + ;; + get) + wq_get "$@" + ;; + my-queue) + wq_my_queue "$@" + ;; + stale-check) + wq_stale_check "$@" + ;; + *) + echo "Unknown command: $CMD" >&2 + echo "Commands: add, dispatch, update, list, get, my-queue, stale-check" >&2 + exit 1 + ;; +esac diff --git a/skill/bin/wq_add b/skill/bin/wq_add new file mode 100755 index 0000000..34a0cdb --- /dev/null +++ b/skill/bin/wq_add @@ -0,0 +1,39 @@ +#!/bin/bash +# wq_add — submit a new work item + +wq_add() { + local type desc agent project_id priority payload_json + + while [[ $# -gt 0 ]]; do + case "$1" in + --agent) agent="$2"; shift 2;; + --project-id) project_id="$2"; shift 2;; + --priority) priority="$2"; shift 2;; + --payload) payload_json="$2"; shift 2;; + --) shift; break;; + -*) echo "Unknown option: $1" >&2; exit 1;; + *) + if [[ -z "$type" ]]; then + type="$1" + elif [[ -z "$desc" ]]; then + desc="$1" + fi + shift;; + esac + done + + if [[ -z "$type" ]] || [[ -z "$desc" ]]; then + echo "Usage: wq add [--agent ] [--project-id ] [--priority 1-5>] [--payload ]" >&2 + exit 1 + fi + + local body="{\"type\":\"$type\",\"description\":\"$desc\"}" + [[ -n "$agent" ]] && body=$(echo "$body" | jq ".assigned_agent=\"$agent\"") + [[ -n "$project_id" ]] && body=$(echo "$body" | jq ".project_id=\"$project_id\"") + [[ -n "$priority" ]] && body=$(echo "$body" | jq ".priority=$priority") + [[ -n "$payload_json" ]] && body=$(echo "$body" | jq ".payload=$payload_json") + + curl -sf -X POST "$API_URL/work" \ + -H "Content-Type: application/json" \ + -d "$body" | jq . +} diff --git a/skill/bin/wq_dispatch b/skill/bin/wq_dispatch new file mode 100755 index 0000000..0fac5bb --- /dev/null +++ b/skill/bin/wq_dispatch @@ -0,0 +1,27 @@ +#!/bin/bash +# wq_dispatch — dispatch queued item to agent (queued→dispatched→in_progress atomically) + +wq_dispatch() { + local work_id="$1" agent="$2" + + if [[ -z "$work_id" ]] || [[ -z "$agent" ]]; then + echo "Usage: wq dispatch " >&2 + exit 1 + fi + + # Check if agent already has in_progress + local existing + existing=$(curl -sf "$API_URL/work?status=in_progress&agent=$agent" | jq 'length') + if [[ "$existing" -gt 0 ]]; then + echo "Error: $agent already has an in_progress item" >&2 + exit 1 + fi + + curl -sf -X PATCH "$API_URL/work/$work_id" \ + -H "Content-Type: application/json" \ + -d "{\"status\":\"dispatched\",\"assigned_agent\":\"$agent\"}" | jq . + + curl -sf -X PATCH "$API_URL/work/$work_id" \ + -H "Content-Type: application/json" \ + -d "{\"status\":\"in_progress\"}" | jq . +} diff --git a/skill/bin/wq_get b/skill/bin/wq_get new file mode 100755 index 0000000..610371b --- /dev/null +++ b/skill/bin/wq_get @@ -0,0 +1,8 @@ +#!/bin/bash +# wq_get — get single work item + +wq_get() { + local work_id="$1" + [[ -z "$work_id" ]] && { echo "Usage: wq get " >&2; exit 1; } + curl -sf "$API_URL/work/$work_id" | jq . +} diff --git a/skill/bin/wq_list b/skill/bin/wq_list new file mode 100755 index 0000000..991ef0e --- /dev/null +++ b/skill/bin/wq_list @@ -0,0 +1,25 @@ +#!/bin/bash +# wq_list — list work items with optional filters + +wq_list() { + local status agent project_id since qs="?" + + while [[ $# -gt 0 ]]; do + case "$1" in + --status) status="$2"; shift 2;; + --agent) agent="$2"; shift 2;; + --project-id) project_id="$2"; shift 2;; + --since) since="$2"; shift 2;; + --) shift; break;; + -*) echo "Unknown option: $1" >&2; exit 1;; + *) echo "Unknown arg: $1" >&2; exit 1;; + esac + done + + [[ -n "$status" ]] && qs="${qs}status=$status&" + [[ -n "$agent" ]] && qs="${qs}agent=$agent&" + [[ -n "$project_id" ]] && qs="${qs}project_id=$project_id&" + [[ -n "$since" ]] && qs="${qs}since=$since&" + + curl -sf "$API_URL/work${qs}" | jq . +} diff --git a/skill/bin/wq_my_queue b/skill/bin/wq_my_queue new file mode 100755 index 0000000..613ce20 --- /dev/null +++ b/skill/bin/wq_my_queue @@ -0,0 +1,8 @@ +#!/bin/bash +# wq_my_queue — list dispatched items for a given agent (what Steve polls) + +wq_my_queue() { + local agent="$1" + [[ -z "$agent" ]] && { echo "Usage: wq my-queue " >&2; exit 1; } + curl -sf "$API_URL/work?status=dispatched&agent=$agent" | jq . +} diff --git a/skill/bin/wq_stale_check b/skill/bin/wq_stale_check new file mode 100755 index 0000000..e4bd487 --- /dev/null +++ b/skill/bin/wq_stale_check @@ -0,0 +1,35 @@ +#!/bin/bash +# wq_stale_check — find in_progress items older than timeout, mark blocked + +wq_stale_check() { + local timeout_minutes="${1:-30}" + + local items + items=$(curl -sf "$API_URL/work?status=in_progress" | jq -r '.[] | @json' 2>/dev/null || echo "") + + if [[ -z "$items" ]]; then + echo "No in_progress items found." + return 0 + fi + + local count=0 + now_ts=$(date -u +%s) + + while IFS= read -r item; do + [[ -z "$item" ]] && continue + work_id=$(echo "$item" | jq -r '.id') + updated_at=$(echo "$item" | jq -r '.updated_at') + agent=$(echo "$item" | jq -r '.assigned_agent') + age_minutes=$(( (now_ts - $(date -u -d "$updated_at" +%s 2>/dev/null || echo "$now_ts")) / 60 )) + + if [[ "$age_minutes" -gt "$timeout_minutes" ]]; then + echo "Stale: $work_id ($agent, ${age_minutes}m old) — marking blocked" + curl -sf -X PATCH "$API_URL/work/$work_id" \ + -H "Content-Type: application/json" \ + -d "{\"status\":\"blocked\",\"notes\":\"Auto-blocked: stale for ${age_minutes} minutes (>$timeout_minutes)\"}" | jq -r '.id' > /dev/null + count=$((count + 1)) + fi + done <<< "$items" + + echo "Stale check complete: $count items marked blocked." +} diff --git a/skill/bin/wq_update b/skill/bin/wq_update new file mode 100755 index 0000000..c4f5c3c --- /dev/null +++ b/skill/bin/wq_update @@ -0,0 +1,40 @@ +#!/bin/bash +# wq_update — update status, outcome, or notes + +wq_update() { + local work_id status outcome notes + + while [[ $# -gt 0 ]]; do + case "$1" in + --status) status="$2"; shift 2;; + --outcome) outcome="$2"; shift 2;; + --notes) notes="$2"; shift 2;; + --) shift; break;; + -*) echo "Unknown option: $1" >&2; exit 1;; + *) + if [[ -z "$work_id" ]]; then + work_id="$1" + fi + shift;; + esac + done + + if [[ -z "$work_id" ]]; then + echo "Usage: wq update [--status ] [--outcome ] [--notes ]" >&2 + exit 1 + fi + + if [[ -z "$status" ]] && [[ -z "$outcome" ]] && [[ -z "$notes" ]]; then + echo "Error: at least one of --status, --outcome, or --notes required" >&2 + exit 1 + fi + + local body="{}" + [[ -n "$status" ]] && body=$(echo "$body" | jq ".status=\"$status\"") + [[ -n "$outcome" ]] && body=$(echo "$body" | jq ".outcome=\"$outcome\"") + [[ -n "$notes" ]] && body=$(echo "$body" | jq ".notes=\"$notes\"") + + curl -sf -X PATCH "$API_URL/work/$work_id" \ + -H "Content-Type: application/json" \ + -d "$body" | jq . +}