#!/usr/bin/env bash # Metadata-backed task lifecycle. Never commits, stashes, resets, or force-pushes. set -euo pipefail script_root=$(cd "$(dirname "$0")/.." && pwd) primary=$script_root if [ -f "$script_root/.git" ]; then common=$(git -C "$script_root" rev-parse --path-format=absolute --git-common-dir) case "$common" in */.git) primary=${common%/.git} ;; esac fi worktree_root=${MISALIGNED_WORKTREE_ROOT:-.Codex/worktrees} case "$worktree_root" in /*) ;; *) worktree_root="$primary/$worktree_root" ;; esac LANDING_LOCK_DIR=${MISALIGNED_LANDING_LOCK:-/tmp/misaligned-landing.lock} landing_lock_held=0 release_landing_lock() { if [ "$landing_lock_held" -eq 1 ]; then local holder holder=$(cat "$LANDING_LOCK_DIR/pid" 2>/dev/null || true) if [ "$holder" = "$$" ]; then rm -rf "$LANDING_LOCK_DIR" fi landing_lock_held=0 fi } clear_stale_landing_lock() { local stale="$LANDING_LOCK_DIR.stale.$$" if mv "$LANDING_LOCK_DIR" "$stale" 2>/dev/null; then rm -rf "$stale" return 0 fi return 1 } acquire_landing_lock() { local waited=0 holder while ! mkdir "$LANDING_LOCK_DIR" 2>/dev/null; do holder=$(cat "$LANDING_LOCK_DIR/pid" 2>/dev/null || true) if [ -z "$holder" ]; then # mkdir and pid write are separate operations. Give a live acquirer one # second to publish its pid before treating a pidless directory as stale. sleep 1 holder=$(cat "$LANDING_LOCK_DIR/pid" 2>/dev/null || true) fi if [ -n "$holder" ] && ! kill -0 "$holder" 2>/dev/null; then echo "task: clearing stale landing lock (dead pid $holder)" clear_stale_landing_lock || true continue fi if [ -z "$holder" ]; then echo "task: clearing stale landing lock (no holder pid)" clear_stale_landing_lock || true continue fi if [ "${MISALIGNED_LANDING_WAIT:-1}" = "0" ]; then echo "FAIL: another task is landing (pid ${holder:-unknown})" >&2 return 1 fi if [ "$waited" -eq 0 ]; then echo "task: waiting for landing lock (pid ${holder:-unknown})" fi waited=$((waited + 1)) sleep 1 done printf '%s\n' "$$" > "$LANDING_LOCK_DIR/pid" landing_lock_held=1 trap release_landing_lock EXIT trap 'release_landing_lock; exit 130' INT TERM HUP echo "task: acquired landing lock (pid $$)" } usage() { cat <<'EOF' usage: tools/task.sh start [--dry-run] tools/task.sh status [--json|--offline] tools/task.sh check tools/task.sh finish tools/task.sh abandon finish requires a clean committed task branch and clean current primary main. It rebases, checks, fast-forwards, pushes main, publishes pages, then removes the task worktree. EOF } task_path() { case "$1" in ""|*/*|*..*) echo "FAIL: invalid task slug: $1" >&2; exit 2 ;; esac printf '%s/%s\n' "$worktree_root" "$1" } require_worktree() { local wt wt=$(task_path "$1") [ -d "$wt" ] || { echo "FAIL: task worktree does not exist: $wt" >&2; exit 1; } printf '%s\n' "$wt" } cmd_start() { local spec=${1:-} dry_run=0 source_root=$primary shift || true [ -n "$spec" ] || { usage >&2; exit 2; } if [ "${1:-}" = "--dry-run" ]; then dry_run=1; shift; fi [ $# -eq 0 ] || { usage >&2; exit 2; } [ -f "$source_root/tools/work_orders.py" ] || source_root=$script_root local task="" class="" key value local -a keys=() while IFS='=' read -r key value; do case "$key" in task) task=$value ;; class) class=$value ;; key) keys+=("$value") ;; esac done < <(python3 "$source_root/tools/work_orders.py" task-values "$spec" --root "$source_root") [ -n "$task" ] && [ -n "$class" ] || { echo "FAIL: incomplete metadata for $spec" >&2; exit 1; } local -a args=("$task" --class "$class") for value in "${keys[@]}"; do args+=(--key "$value"); done if [ "$dry_run" -eq 1 ]; then printf 'task start:' printf ' %q' "$primary/tools/worktree-new.sh" "${args[@]}" printf '\n' return 0 fi bash "$primary/tools/worktree-new.sh" "${args[@]}" local wt wt=$(task_path "$task") bash "$wt/tools/heartbeat.sh" start "$task" --worktree "$wt" --phase boot } cmd_check() { local task=${1:-} wt [ -n "$task" ] || { usage >&2; exit 2; } wt=$(require_worktree "$task") bash "$wt/tools/claim.sh" set-status "$task" checking bash "$wt/tools/heartbeat.sh" phase "$task" check-land (cd "$wt" && ./tools/check.sh --land) } publish_site() { local revision revision=$(git -C "$primary" rev-parse --verify 'HEAD^{commit}') echo "task: publishing public corpus from $revision" (cd "$primary" && SITE_SOURCE_REVISION="$revision" bash tools/site-deploy.sh) } cmd_finish() { local task=${1:-} wt branch dirty primary_dirty [ -n "$task" ] || { usage >&2; exit 2; } wt=$(require_worktree "$task") branch=$(git -C "$wt" branch --show-current) [ "$branch" = "worktree-$task" ] || { echo "FAIL: $wt is on $branch, expected worktree-$task" >&2; exit 1; } dirty=$(git -C "$wt" status --porcelain) [ -z "$dirty" ] || { echo "FAIL: task worktree has uncommitted changes; commit them before finish" >&2 printf '%s\n' "$dirty" >&2 exit 1 } bash "$wt/tools/heartbeat.sh" phase "$task" landing-wait acquire_landing_lock primary_dirty=$(git -C "$primary" status --porcelain) [ -z "$primary_dirty" ] || { echo "FAIL: primary checkout is dirty; finish will not merge into it" >&2 printf '%s\n' "$primary_dirty" >&2 exit 1 } bash "$wt/tools/claim.sh" set-status "$task" landing bash "$wt/tools/heartbeat.sh" phase "$task" rebase git -C "$wt" fetch origin git -C "$wt" rebase origin/main bash "$wt/tools/heartbeat.sh" phase "$task" check-land (cd "$wt" && ./tools/check.sh --land) git -C "$primary" fetch origin [ "$(git -C "$primary" rev-parse HEAD)" = "$(git -C "$primary" rev-parse origin/main)" ] || { echo "FAIL: primary main is not current origin/main; refusing merge" >&2 exit 1 } bash "$wt/tools/heartbeat.sh" phase "$task" push git -C "$primary" merge --ff-only "worktree-$task" git -C "$primary" push origin main bash "$wt/tools/heartbeat.sh" phase "$task" site-publish publish_site bash "$wt/tools/heartbeat.sh" end "$task" --status ok release_landing_lock bash "$primary/tools/worktree-done.sh" "$task" } cmd_abandon() { local task=${1:-} wt [ -n "$task" ] || { usage >&2; exit 2; } wt=$(require_worktree "$task") # worktree-done refuses dirty state before removing anything. bash "$wt/tools/heartbeat.sh" end "$task" --status fail bash "$primary/tools/worktree-done.sh" "$task" --status abandoned } if [ "${BASH_SOURCE[0]}" = "$0" ]; then command=${1:-}; shift || true case "$command" in start) cmd_start "$@" ;; status) python3 "$script_root/tools/project-status.py" "$@" ;; check) cmd_check "$@" ;; finish) cmd_finish "$@" ;; abandon) cmd_abandon "$@" ;; -h|--help|help) usage ;; *) usage >&2; exit 2 ;; esac fi