feat(agent-queue): profiles (persona + presets) and single-host deps/DAG (P1-S2)
Implements roadmap §6 (profiles) and §5 deps on the bash runner, backward-compatible (jobs without profile/deps behave exactly as before). Profiles (§6): - profile_get / profile_persona / fm_eff helpers + PROFILES_DIR (AGENT_QUEUE_PROFILES override). A job's `profile:` inherits verify (<- default-verify), capabilities, engine-class, prefers-engine, allowed-scope, review-policy when the job omits them; job fields always override (precedence job > profile > default). Resolution runs via fm_eff inside the capability gate and resolve_engine, so inherited caps/engine-class take effect before launch. - persona injection: the profile's persona block is prepended to the stripped body fed to the engine (job .md unchanged on disk; nothing secret logged). - allowed-scope guardrail (WARN-ONLY): scope_check logs a non-blocking WARNING + records scope_warning= for changed paths outside the globs; path_in_scope is a pure, unit-testable matcher (`dir/**` = subtree). deps / DAG, single host (§5): - deps reference other jobs by idempotency-key. dep_satisfied: shipped/ (hard) or shipped/+testing/ (deps-mode: soft). deps_unmet drives a block-with-reason skip in inbox selection (never launched/failed); cmd_status surfaces "blocked (waiting on <keys>)". deps_would_cycle rejects cyclic submits on `add`. - _drain_pending: `--once` drains past dep-blocked jobs (idle can't satisfy them) while still waiting on retry/recovery backoff timers. Meta now records effective (inherited) capabilities/engine-class/prefers-engine/ review-policy/allowed-scope so `status` reflects resolved config.
This commit is contained in:
parent
7c4f5bc9b0
commit
3d99f04427
@ -29,6 +29,8 @@ set -uo pipefail
|
|||||||
# ── Resolve paths ───────────────────────────────────────────────────
|
# ── Resolve paths ───────────────────────────────────────────────────
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
QUEUE_ROOT="${AGENT_QUEUE_ROOT:-$SCRIPT_DIR/queue}"
|
QUEUE_ROOT="${AGENT_QUEUE_ROOT:-$SCRIPT_DIR/queue}"
|
||||||
|
# Profile catalog dir (persona + capability presets). Override for tests.
|
||||||
|
PROFILES_DIR="${AGENT_QUEUE_PROFILES:-$SCRIPT_DIR/profiles}"
|
||||||
INBOX="$QUEUE_ROOT/inbox"
|
INBOX="$QUEUE_ROOT/inbox"
|
||||||
BUILDING="$QUEUE_ROOT/building"
|
BUILDING="$QUEUE_ROOT/building"
|
||||||
REVIEW="$QUEUE_ROOT/review"
|
REVIEW="$QUEUE_ROOT/review"
|
||||||
@ -120,6 +122,51 @@ lock_key_for() {
|
|||||||
# _keyhash <key> -> stable filename-safe token for a lock key
|
# _keyhash <key> -> stable filename-safe token for a lock key
|
||||||
_keyhash() { printf '%s' "$1" | cksum | awk '{print $1}'; }
|
_keyhash() { printf '%s' "$1" | cksum | awk '{print $1}'; }
|
||||||
|
|
||||||
|
# ── Profiles (§6): persona + capability/engine/scope presets ─────────
|
||||||
|
#
|
||||||
|
# profile_get <profile-name> <profile-key> [default] -> a single-line value from
|
||||||
|
# profiles/<name>.md, else the default.
|
||||||
|
profile_get() {
|
||||||
|
local pf="$PROFILES_DIR/$1.md"
|
||||||
|
[[ -f "$pf" ]] || { printf '%s' "${3:-}"; return; }
|
||||||
|
fm_get "$pf" "$2" "${3:-}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# profile_persona <profile-name> -> the multi-line `persona: |` block (2-space
|
||||||
|
# indent stripped), or empty. Used to prepend a persona overlay to the job body.
|
||||||
|
profile_persona() {
|
||||||
|
local pf="$PROFILES_DIR/$1.md"
|
||||||
|
[[ -f "$pf" ]] || return 0
|
||||||
|
awk '
|
||||||
|
NR==1 && $0!="---" { exit }
|
||||||
|
NR==1 { infm=1; next }
|
||||||
|
infm && $0=="---" { exit }
|
||||||
|
!infm { next }
|
||||||
|
inpersona {
|
||||||
|
if ($0 ~ /^[A-Za-z0-9_-]+[ \t]*:/) { inpersona=0 }
|
||||||
|
else { line=$0; sub(/^ /,"",line); print line; next }
|
||||||
|
}
|
||||||
|
$0 ~ /^persona[ \t]*:[ \t]*\|[ \t]*$/ { inpersona=1; next }
|
||||||
|
' "$pf"
|
||||||
|
}
|
||||||
|
|
||||||
|
# fm_eff <file> <job-key> [default] [profile-key] -> effective value with
|
||||||
|
# precedence job > profile > built-in default (§6 resolution). The profile is the
|
||||||
|
# job's `profile:` frontmatter; `profile-key` defaults to `job-key` (e.g. `verify`
|
||||||
|
# inherits the profile's `default-verify`). Inheritable: verify, capabilities,
|
||||||
|
# engine-class, prefers-engine, allowed-scope, review-policy.
|
||||||
|
fm_eff() {
|
||||||
|
local file=$1 jkey=$2 def=${3:-} pkey=${4:-$2} v prof pv
|
||||||
|
v=$(fm_get "$file" "$jkey" "")
|
||||||
|
if [[ -n "$v" ]]; then printf '%s' "$v"; return; fi
|
||||||
|
prof=$(fm_get "$file" profile "")
|
||||||
|
if [[ -n "$prof" ]]; then
|
||||||
|
pv=$(profile_get "$prof" "$pkey" "")
|
||||||
|
[[ -n "$pv" ]] && { printf '%s' "$pv"; return; }
|
||||||
|
fi
|
||||||
|
printf '%s' "$def"
|
||||||
|
}
|
||||||
|
|
||||||
# _mtime <file> -> file modification time in epoch seconds (BSD or GNU stat); empty if missing
|
# _mtime <file> -> file modification time in epoch seconds (BSD or GNU stat); empty if missing
|
||||||
_mtime() {
|
_mtime() {
|
||||||
[[ -e "$1" ]] || { echo ""; return; }
|
[[ -e "$1" ]] || { echo ""; return; }
|
||||||
@ -356,10 +403,10 @@ resolve_engine() {
|
|||||||
local f=$1 eng cls prefers
|
local f=$1 eng cls prefers
|
||||||
eng=$(fm_get "$f" engine "")
|
eng=$(fm_get "$f" engine "")
|
||||||
if [[ -n "$eng" ]]; then printf '%s' "$eng"; return 0; fi
|
if [[ -n "$eng" ]]; then printf '%s' "$eng"; return 0; fi
|
||||||
cls=$(fm_get "$f" engine-class "")
|
cls=$(fm_eff "$f" engine-class "") # inherit engine-class from the job's profile
|
||||||
if [[ -z "$cls" ]]; then printf '%s' "$DEFAULT_ENGINE"; return 0; fi
|
if [[ -z "$cls" ]]; then printf '%s' "$DEFAULT_ENGINE"; return 0; fi
|
||||||
local class_engines; class_engines=$(engine_class_engines "$cls")
|
local class_engines; class_engines=$(engine_class_engines "$cls")
|
||||||
prefers=$(fm_get "$f" prefers-engine "")
|
prefers=$(fm_eff "$f" prefers-engine "")
|
||||||
local ordered=() seen=" " p c
|
local ordered=() seen=" " p c
|
||||||
if [[ -n "$prefers" ]]; then
|
if [[ -n "$prefers" ]]; then
|
||||||
while IFS= read -r p; do
|
while IFS= read -r p; do
|
||||||
@ -377,6 +424,134 @@ resolve_engine() {
|
|||||||
printf '%s' ""
|
printf '%s' ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── deps / DAG, single host (§5) ─────────────────────────────────────
|
||||||
|
# deps reference other jobs by their (author-controlled) `idempotency-key`.
|
||||||
|
#
|
||||||
|
# _key_in_dir <key> <dir> -> 0 if some .md in <dir> has idempotency-key == key.
|
||||||
|
_key_in_dir() {
|
||||||
|
local key=$1 d=$2 ef
|
||||||
|
for ef in "$d"/*.md; do
|
||||||
|
[[ -e "$ef" ]] || continue
|
||||||
|
[[ "$(fm_get "$ef" idempotency-key "")" == "$key" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# dep_satisfied <key> <mode> -> 0 when the dep is met: a job with <key> is in
|
||||||
|
# shipped/ (default), or shipped/ OR testing/ when mode is `soft`.
|
||||||
|
dep_satisfied() {
|
||||||
|
local key=$1 mode=$2
|
||||||
|
_key_in_dir "$key" "$SHIPPED" && return 0
|
||||||
|
[[ "$mode" == soft ]] && _key_in_dir "$key" "$TESTING" && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# deps_unmet <file> -> space-separated list of this job's UNMET dep keys (empty if
|
||||||
|
# none / no deps). `deps-mode` (hard|soft) is job-level.
|
||||||
|
deps_unmet() {
|
||||||
|
local f=$1 keys mode k unmet=""
|
||||||
|
keys=$(parse_list "$(fm_get "$f" deps "")" | tr '\n' ' ')
|
||||||
|
[[ -n "${keys// /}" ]] || { printf ''; return 0; }
|
||||||
|
mode=$(fm_get "$f" deps-mode "hard")
|
||||||
|
for k in $keys; do
|
||||||
|
[[ -n "$k" ]] || continue
|
||||||
|
dep_satisfied "$k" "$mode" || unmet+="$k "
|
||||||
|
done
|
||||||
|
printf '%s' "${unmet% }"
|
||||||
|
}
|
||||||
|
|
||||||
|
# _deps_of_key <key> -> dep keys (space-separated) of the job carrying <key>,
|
||||||
|
# scanned across inbox + active stages.
|
||||||
|
_deps_of_key() {
|
||||||
|
local key=$1 d ef
|
||||||
|
for d in "$INBOX" "$BUILDING" "$REVIEW" "$TESTING" "$SHIPPED"; do
|
||||||
|
for ef in "$d"/*.md; do
|
||||||
|
[[ -e "$ef" ]] || continue
|
||||||
|
[[ "$(fm_get "$ef" idempotency-key "")" == "$key" ]] || continue
|
||||||
|
parse_list "$(fm_get "$ef" deps "")" | tr '\n' ' '
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# deps_would_cycle <new-key> <new-deps-space> -> 0 if adding a job with <new-key>
|
||||||
|
# depending on <new-deps> would create a cycle (BFS over existing key->deps edges
|
||||||
|
# back to new-key; also catches self-dependency).
|
||||||
|
deps_would_cycle() {
|
||||||
|
local newkey=$1 newdeps=$2 visited=" " frontier next k d kd
|
||||||
|
[[ -n "$newkey" ]] || return 1
|
||||||
|
_in_list "$newkey" "$newdeps" && return 0
|
||||||
|
frontier="$newdeps"
|
||||||
|
while [[ -n "${frontier// /}" ]]; do
|
||||||
|
next=""
|
||||||
|
for k in $frontier; do
|
||||||
|
[[ -n "$k" ]] || continue
|
||||||
|
[[ "$k" == "$newkey" ]] && return 0
|
||||||
|
case "$visited" in *" $k "*) continue;; esac
|
||||||
|
visited+="$k "
|
||||||
|
kd=$(_deps_of_key "$k")
|
||||||
|
for d in $kd; do next+="$d "; done
|
||||||
|
done
|
||||||
|
frontier="$next"
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# _drain_pending -> 0 if some inbox job can still make progress on its own: it is
|
||||||
|
# runnable now, or it is waiting on a retry/recovery backoff (which elapses with
|
||||||
|
# time). A job blocked ONLY by unmet deps is NOT pending while the loop is idle
|
||||||
|
# (no running job can satisfy its deps), so `--once` can drain past it.
|
||||||
|
_drain_pending() {
|
||||||
|
local cand cj ne now; now=$(date +%s)
|
||||||
|
for cand in "$INBOX"/*.md; do
|
||||||
|
[[ -e "$cand" ]] || continue
|
||||||
|
cj=$(basename "$cand"); cj=${cj%.md}
|
||||||
|
ne=$(grep '^next_eligible=' "$STATE/$cj.meta" 2>/dev/null | tail -1 | cut -d= -f2)
|
||||||
|
if [[ "$ne" =~ ^[0-9]+$ ]] && [[ "$ne" -gt "$now" ]]; then return 0; fi
|
||||||
|
[[ -n "$(deps_unmet "$cand")" ]] && continue
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── allowed-scope guardrail (§6/§12) — WARN-ONLY this phase ───────────
|
||||||
|
# path_in_scope <path> <globs-space> -> 0 if <path> matches any allowed-scope glob
|
||||||
|
# (`dir/**` matches the whole subtree; `*` matches across `/`). Pure + testable.
|
||||||
|
path_in_scope() {
|
||||||
|
local path=$1 globs=$2 g pat
|
||||||
|
for g in $globs; do
|
||||||
|
[[ -n "$g" ]] || continue
|
||||||
|
pat=${g//\*\*/\*}
|
||||||
|
# shellcheck disable=SC2053
|
||||||
|
[[ "$path" == $pat ]] && return 0
|
||||||
|
[[ "$path" == "$g"/* ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# scope_check <cwd> <base> <scope> <logf> <metaf> -> log a WARNING (non-blocking)
|
||||||
|
# for changed paths outside allowed-scope. Records scope_warning= in the meta.
|
||||||
|
scope_check() {
|
||||||
|
local cwd=$1 base=$2 scope=$3 logf=$4 metaf=$5 changed globs p out=""
|
||||||
|
_is_git_repo "$cwd" || return 0
|
||||||
|
if [[ -n "$base" ]]; then
|
||||||
|
changed=$(git -C "$cwd" diff --name-only "$base" HEAD 2>/dev/null)
|
||||||
|
else
|
||||||
|
changed=$(git -C "$cwd" diff --name-only HEAD 2>/dev/null)
|
||||||
|
fi
|
||||||
|
[[ -n "$changed" ]] || return 0
|
||||||
|
globs=$(parse_list "$scope" | tr '\n' ' ')
|
||||||
|
[[ -n "${globs// /}" ]] || return 0
|
||||||
|
while IFS= read -r p; do
|
||||||
|
[[ -n "$p" ]] || continue
|
||||||
|
path_in_scope "$p" "$globs" || out+="$p "
|
||||||
|
done <<< "$changed"
|
||||||
|
if [[ -n "$out" ]]; then
|
||||||
|
echo "WARNING: allowed-scope violation (warn-only) — changed outside [$globs]: ${out% }" >> "$logf"
|
||||||
|
echo "scope_warning=${out% }" >> "$metaf"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ── Engine driver: builds argv into AGENT_CMD[]; sets AGENT_STDIN if the ──
|
# ── Engine driver: builds argv into AGENT_CMD[]; sets AGENT_STDIN if the ──
|
||||||
# prompt should be fed on stdin (claude/codex) rather than a flag. $pf is the
|
# prompt should be fed on stdin (claude/codex) rather than a flag. $pf is the
|
||||||
# frontmatter-STRIPPED body file, so a body starting with '--' is never
|
# frontmatter-STRIPPED body file, so a body starting with '--' is never
|
||||||
@ -424,8 +599,9 @@ run_worker() {
|
|||||||
# The worker only ever APPENDS (ended/exit/result) to avoid a truncation race.
|
# The worker only ever APPENDS (ended/exit/result) to avoid a truncation race.
|
||||||
|
|
||||||
# ── Capability gate (§5/§8 single-host): if the job declares `capabilities`
|
# ── Capability gate (§5/§8 single-host): if the job declares `capabilities`
|
||||||
# this host does not satisfy, route to failed/ WITHOUT launching the agent. ──
|
# (own or inherited from its profile) this host does not satisfy, route to
|
||||||
local req_caps; req_caps=$(parse_list "$(fm_get "$doing_file" capabilities "")" | tr '\n' ' ')
|
# failed/ WITHOUT launching the agent. ──
|
||||||
|
local req_caps; req_caps=$(parse_list "$(fm_eff "$doing_file" capabilities "")" | tr '\n' ' ')
|
||||||
if [[ -n "${req_caps// /}" ]]; then
|
if [[ -n "${req_caps// /}" ]]; then
|
||||||
local avail; avail=$(detect_capabilities)
|
local avail; avail=$(detect_capabilities)
|
||||||
if ! caps_match "$req_caps" "$avail"; then
|
if ! caps_match "$req_caps" "$avail"; then
|
||||||
@ -474,6 +650,16 @@ run_worker() {
|
|||||||
# Strip our frontmatter so the agent only sees the task body.
|
# Strip our frontmatter so the agent only sees the task body.
|
||||||
local bodyf="$STATE/$job.body.md"
|
local bodyf="$STATE/$job.body.md"
|
||||||
strip_frontmatter "$doing_file" > "$bodyf"
|
strip_frontmatter "$doing_file" > "$bodyf"
|
||||||
|
# ── Persona injection (§6): prepend the profile's persona to the body fed to
|
||||||
|
# the engine (job body unchanged on disk). Secrets are never logged. ──
|
||||||
|
local prof; prof=$(fm_get "$doing_file" profile "")
|
||||||
|
if [[ -n "$prof" ]]; then
|
||||||
|
local persona; persona=$(profile_persona "$prof")
|
||||||
|
if [[ -n "$persona" ]]; then
|
||||||
|
{ printf '%s\n\n' "$persona"; cat "$bodyf"; } > "$bodyf.tmp" && mv "$bodyf.tmp" "$bodyf"
|
||||||
|
echo "profile: injected persona overlay from '$prof'" >> "$logf"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
build_agent_cmd "$engine" "$bodyf" "$yolo"
|
build_agent_cmd "$engine" "$bodyf" "$yolo"
|
||||||
|
|
||||||
# ── WIP checkpoint setup (§25.2): on a git cwd, create/checkout aq/wip/<job>
|
# ── WIP checkpoint setup (§25.2): on a git cwd, create/checkout aq/wip/<job>
|
||||||
@ -547,6 +733,11 @@ run_worker() {
|
|||||||
_numstat_into_meta "$cwd" "$WIP_BASE" "$metaf"
|
_numstat_into_meta "$cwd" "$WIP_BASE" "$metaf"
|
||||||
parse_usage "$engine" "$logf" >> "$metaf"
|
parse_usage "$engine" "$logf" >> "$metaf"
|
||||||
|
|
||||||
|
# ── allowed-scope guardrail (§6) — WARN-ONLY: flag out-of-scope changes but
|
||||||
|
# never block the job this phase. Scope may be inherited from the profile. ──
|
||||||
|
local scope; scope=$(fm_eff "$doing_file" allowed-scope "" allowed-scope)
|
||||||
|
[[ -n "$scope" ]] && scope_check "$cwd" "$WIP_BASE" "$scope" "$logf" "$metaf"
|
||||||
|
|
||||||
if $timed_out; then
|
if $timed_out; then
|
||||||
echo "TIMED OUT after ${tmo}s (rc=$rc): $(date)" >> "$logf"
|
echo "TIMED OUT after ${tmo}s (rc=$rc): $(date)" >> "$logf"
|
||||||
_finish_failure "$job" "$doing_file" "$metaf" "$logf" "timeout" "$rc" "$started"
|
_finish_failure "$job" "$doing_file" "$metaf" "$logf" "timeout" "$rc" "$started"
|
||||||
@ -558,7 +749,8 @@ run_worker() {
|
|||||||
local review_file="$REVIEW/$job.md"
|
local review_file="$REVIEW/$job.md"
|
||||||
echo "exit=$rc" >> "$metaf"
|
echo "exit=$rc" >> "$metaf"
|
||||||
echo "completed OK (rc=0): landed in review — $(date)" >> "$logf"
|
echo "completed OK (rc=0): landed in review — $(date)" >> "$logf"
|
||||||
local verify; verify=$(fm_get "$review_file" verify "$DEFAULT_VERIFY")
|
# verify is job-level, else inherited from the profile's default-verify.
|
||||||
|
local verify; verify=$(fm_eff "$review_file" verify "$DEFAULT_VERIFY" default-verify)
|
||||||
if [[ -z "$verify" ]]; then
|
if [[ -z "$verify" ]]; then
|
||||||
_meta_end "$metaf" "review" "$started"
|
_meta_end "$metaf" "review" "$started"
|
||||||
echo "no verify command — parked in review for manual promote: $(date)" >> "$logf"
|
echo "no verify command — parked in review for manual promote: $(date)" >> "$logf"
|
||||||
@ -896,6 +1088,13 @@ cmd_add() {
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── dep cycle detection (§5): reject a submit that would create a cycle in the
|
||||||
|
# idempotency-key dependency graph (inbox + active stages). ──
|
||||||
|
local newdeps; newdeps=$(parse_list "$(fm_get "$file" deps "")" | tr '\n' ' ')
|
||||||
|
if [[ -n "${newdeps// /}" ]] && deps_would_cycle "$idem" "$newdeps"; then
|
||||||
|
die "dependency cycle detected: job (key '${idem:-<none>}') with deps [${newdeps% }] would create a cycle — refusing."
|
||||||
|
fi
|
||||||
|
|
||||||
local base; base=$(basename "$file")
|
local base; base=$(basename "$file")
|
||||||
local stamp; stamp=$(date +%Y%m%d-%H%M%S)
|
local stamp; stamp=$(date +%Y%m%d-%H%M%S)
|
||||||
local dest="$INBOX/${stamp}__${base}"
|
local dest="$INBOX/${stamp}__${base}"
|
||||||
@ -963,6 +1162,8 @@ cmd_run() {
|
|||||||
cand_job=$(basename "$cand"); cand_job=${cand_job%.md}
|
cand_job=$(basename "$cand"); cand_job=${cand_job%.md}
|
||||||
cand_ne=$(grep '^next_eligible=' "$STATE/$cand_job.meta" 2>/dev/null | tail -1 | cut -d= -f2)
|
cand_ne=$(grep '^next_eligible=' "$STATE/$cand_job.meta" 2>/dev/null | tail -1 | cut -d= -f2)
|
||||||
if [[ "$cand_ne" =~ ^[0-9]+$ ]] && [[ "$cand_ne" -gt "$now_s" ]]; then continue; fi
|
if [[ "$cand_ne" =~ ^[0-9]+$ ]] && [[ "$cand_ne" -gt "$now_s" ]]; then continue; fi
|
||||||
|
# skip jobs whose deps (§5 DAG) are unmet — blocked, re-evaluated next loop
|
||||||
|
if [[ -n "$(deps_unmet "$cand")" ]]; then continue; fi
|
||||||
next="$cand"; break
|
next="$cand"; break
|
||||||
done < <(inbox_sorted)
|
done < <(inbox_sorted)
|
||||||
[[ -z "$next" ]] && break
|
[[ -z "$next" ]] && break
|
||||||
@ -997,16 +1198,17 @@ cmd_run() {
|
|||||||
echo "attempts=$w_attempts"
|
echo "attempts=$w_attempts"
|
||||||
echo "priority=$(fm_get "$doing_file" priority medium)"
|
echo "priority=$(fm_get "$doing_file" priority medium)"
|
||||||
echo "profile=$(fm_get "$doing_file" profile "")"
|
echo "profile=$(fm_get "$doing_file" profile "")"
|
||||||
echo "engine_class=$(fm_get "$doing_file" engine-class "")"
|
echo "engine_class=$(fm_eff "$doing_file" engine-class "")"
|
||||||
echo "capabilities=$(fm_get "$doing_file" capabilities "")"
|
echo "capabilities=$(fm_eff "$doing_file" capabilities "")"
|
||||||
echo "prefers=$(fm_get "$doing_file" prefers "")"
|
echo "prefers=$(fm_get "$doing_file" prefers "")"
|
||||||
echo "prefers_engine=$(fm_get "$doing_file" prefers-engine "")"
|
echo "prefers_engine=$(fm_eff "$doing_file" prefers-engine "")"
|
||||||
|
echo "allowed_scope=$(fm_eff "$doing_file" allowed-scope "" allowed-scope)"
|
||||||
echo "budget=$(fm_get "$doing_file" budget "")"
|
echo "budget=$(fm_get "$doing_file" budget "")"
|
||||||
echo "deps=$(fm_get "$doing_file" deps "")"
|
echo "deps=$(fm_get "$doing_file" deps "")"
|
||||||
echo "deps_mode=$(fm_get "$doing_file" deps-mode "")"
|
echo "deps_mode=$(fm_get "$doing_file" deps-mode "")"
|
||||||
echo "idempotency_key=$(fm_get "$doing_file" idempotency-key "")"
|
echo "idempotency_key=$(fm_get "$doing_file" idempotency-key "")"
|
||||||
echo "retry=$(fm_get "$doing_file" retry "")"
|
echo "retry=$(fm_get "$doing_file" retry "")"
|
||||||
echo "review_policy=$(fm_get "$doing_file" review-policy "")"
|
echo "review_policy=$(fm_eff "$doing_file" review-policy "" review-policy)"
|
||||||
echo "artifacts=$(fm_get "$doing_file" artifacts "")"
|
echo "artifacts=$(fm_get "$doing_file" artifacts "")"
|
||||||
echo "tracker_item=$(fm_get "$doing_file" tracker-item "")"
|
echo "tracker_item=$(fm_get "$doing_file" tracker-item "")"
|
||||||
} > "$STATE/$job.meta"
|
} > "$STATE/$job.meta"
|
||||||
@ -1018,8 +1220,11 @@ cmd_run() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
if $once; then
|
if $once; then
|
||||||
[[ "$(active_workers)" -eq 0 && -z "$(ls -1 "$INBOX"/*.md 2>/dev/null)" ]] && {
|
# drain when no worker is running and nothing in inbox can still progress on
|
||||||
log "drain complete — inbox empty, no workers running"; rm -f "$STATE/daemon.pid"; exit 0; }
|
# its own (backoff jobs still count as pending; dep-blocked jobs do not).
|
||||||
|
if [[ "$(active_workers)" -eq 0 ]] && ! _drain_pending; then
|
||||||
|
log "drain complete — no runnable work, no workers running"; rm -f "$STATE/daemon.pid"; exit 0
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
sleep "$POLL_SECONDS"
|
sleep "$POLL_SECONDS"
|
||||||
done
|
done
|
||||||
@ -1072,6 +1277,18 @@ cmd_status() {
|
|||||||
printf ' %s%s%s\n' "$C_DIM" "$(_insights_line "$f")" "$C_RESET"
|
printf ' %s%s%s\n' "$C_DIM" "$(_insights_line "$f")" "$C_RESET"
|
||||||
done
|
done
|
||||||
$printed || printf ' %sno workers running%s\n' "$C_DIM" "$C_RESET"
|
$printed || printf ' %sno workers running%s\n' "$C_DIM" "$C_RESET"
|
||||||
|
|
||||||
|
# blocked jobs (unmet deps, §5) — waiting in inbox/, re-evaluated each loop
|
||||||
|
local bf bj unmet bprinted=false
|
||||||
|
for bf in "$INBOX"/*.md; do
|
||||||
|
[[ -e "$bf" ]] || continue
|
||||||
|
unmet=$(deps_unmet "$bf")
|
||||||
|
[[ -n "$unmet" ]] || continue
|
||||||
|
if ! $bprinted; then echo; printf ' %sBLOCKED%s\n' "$C_BOLD" "$C_RESET"; bprinted=true; fi
|
||||||
|
bj=$(basename "$bf"); bj=${bj%.md}
|
||||||
|
printf ' %s%-26s%s %sblocked (waiting on: %s)%s\n' \
|
||||||
|
"$C_BOLD" "$bj" "$C_RESET" "$C_YEL" "$unmet" "$C_RESET"
|
||||||
|
done
|
||||||
echo
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1328,10 +1545,18 @@ ${C_BOLD}TASK FRONTMATTER${C_RESET} (top of each .md)
|
|||||||
capabilities: [os:any, node>=20, has:git] # hard host requirements; unmet -> failed (capability_mismatch)
|
capabilities: [os:any, node>=20, has:git] # hard host requirements; unmet -> failed (capability_mismatch)
|
||||||
idempotency-key: my-task-1 # re-adding same key+body = no-op; same key+different body = reject/supersede
|
idempotency-key: my-task-1 # re-adding same key+body = no-op; same key+different body = reject/supersede
|
||||||
retry: { max: 2, backoff: 5m, on: [timeout, verify_failed, crash] } # requeue on these classes up to max, then retries_exhausted
|
retry: { max: 2, backoff: 5m, on: [timeout, verify_failed, crash] } # requeue on these classes up to max, then retries_exhausted
|
||||||
|
profile: backend-engineer # inherit persona + verify/caps/engine-class/scope/review-policy (job fields override)
|
||||||
|
deps: [other-key] # block until each idempotency-key is shipped/ (or testing/ if deps-mode: soft)
|
||||||
|
deps-mode: soft # soft = a dep also counts as satisfied while in testing/
|
||||||
# --- reserved (parsed + shown in status, but no-op until a later phase) ---
|
# --- reserved (parsed + shown in status, but no-op until a later phase) ---
|
||||||
profile: prefers: budget: deps: deps-mode: review-policy: artifacts: tracker-item:
|
prefers: budget: review-policy: artifacts: tracker-item:
|
||||||
---
|
---
|
||||||
|
|
||||||
|
${C_BOLD}PROFILES${C_RESET} profiles/<name>.md presets persona + capabilities + default-verify + engine-class +
|
||||||
|
prefers-engine + allowed-scope + review-policy. A job's own fields always override.
|
||||||
|
Catalog: developer, backend-engineer, frontend-engineer, ux-designer, ui-designer,
|
||||||
|
qa, reviewer, docs-writer, planner(reserved).
|
||||||
|
|
||||||
${C_BOLD}RESILIENCE${C_RESET} crash-safe: orphaned building/ jobs (dead worker) are recovered to inbox/ on
|
${C_BOLD}RESILIENCE${C_RESET} crash-safe: orphaned building/ jobs (dead worker) are recovered to inbox/ on
|
||||||
'run' startup; git-repo cwd work is checkpointed to branch aq/wip/<job> on every
|
'run' startup; git-repo cwd work is checkpointed to branch aq/wip/<job> on every
|
||||||
exit (resumed on retry); 'retry' requeues failures with backoff. See 'insights'.
|
exit (resumed on retry); 'retry' requeues failures with backoff. See 'insights'.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user