diff --git a/tools/run-plan.sh b/tools/run-plan.sh index 9a34b3c..f5eb48c 100755 --- a/tools/run-plan.sh +++ b/tools/run-plan.sh @@ -5,13 +5,20 @@ # # Usage: tools/run-plan.sh docs/plans/M2a [FIRST] FIRST is a task number such as 04 # Options (environment): -# OPENCODE_MODEL model to use (default straylight/laguna-s-2.1; OpenCode's own default differs). -# The part after the provider (`ornith-1.5-35b-a3b`) names the model in each -# commit's `Co-Authored-By: ` trailer and in the log. -# OPENCODE_FLAGS extra flags for `opencode run`, for example "--pure" +# OPENCODE_MODEL model to use (default straylight/laguna-s-2.1; OpenCode's own default differs). +# The part after the provider (`ornith-1.5-35b-a3b`) names the model in each +# commit's `Co-Authored-By: ` trailer and in the log. +# OPENCODE_FLAGS extra flags for `opencode run`, for example "--pure" +# SESSION_LIMIT_MIN minutes a session may run before it is stopped (default 90) +# SLOTS_URL llama-server's slots URL, for example +# http://127.0.0.1:8081/slots?model=ornith-1.5-35b-a3b. When set, a session is +# stopped once no slot has been busy for IDLE_LIMIT_MIN minutes (default 10). +# Trial: not yet used on a run. +# RESUME=1 when a session ends with work left uncommitted, resume it once. Trial: not +# yet used on a run. +# DRY_RUN=1 print what would be run and stop # Each session runs as the `implement` agent of ./opencode.json: its permissions deny edits to the # plans and design documents, reads outside the repository, subagents and pushes. -# DRY_RUN=1 print what would be run and stop set -eu plan="${1:?usage: tools/run-plan.sh docs/plans/ [FIRST]}" @@ -19,6 +26,8 @@ first="${2:-00}" model="${OPENCODE_MODEL:-straylight/laguna-s-2.1}" agent="${model##*/}" trailer="Co-Authored-By: $agent " +limit="${SESSION_LIMIT_MIN:-90}" +idle="${IDLE_LIMIT_MIN:-10}" log=docs/implementer-log.md milestone=$(basename "$plan") runs=".state/runs/$milestone" @@ -27,6 +36,49 @@ runs=".state/runs/$milestone" [ -f "$log" ] || { echo "run-plan: run this from the repository root" >&2; exit 2; } mkdir -p "$runs" +# One OpenCode session with prompt $1, appended to the task's run log. Returns opencode's status, +# 124 when the session outlived $limit minutes, or 125 when SLOTS_URL showed no busy slot for $idle +# minutes. A stopped session is killed by process group (it may have started cargo), never with +# `pkill`, which would match this script too. +run_session() { + set -m + # shellcheck disable=SC2086 + opencode run --agent implement -m "$model" ${OPENCODE_FLAGS:-} --title "$milestone/$name" "$1" \ + >> "$runs/$name.log" 2>&1 & + pid=$! + set +m + started=$(date +%s) + busy=$started + why=0 + while kill -0 "$pid" 2> /dev/null; do + sleep 10 + now=$(date +%s) + if [ $((now - started)) -ge $((limit * 60)) ]; then + why=124 + break + fi + if [ -n "${SLOTS_URL:-}" ]; then + if curl -s -m 10 "$SLOTS_URL" | grep -Eq '"is_processing": *true'; then + busy=$now + fi + if [ $((now - busy)) -ge $((idle * 60)) ]; then + why=125 + break + fi + fi + done + if [ "$why" != 0 ]; then + kill -TERM "-$pid" 2> /dev/null || kill -TERM "$pid" 2> /dev/null || true + sleep 30 + kill -KILL "-$pid" 2> /dev/null || true + wait "$pid" 2> /dev/null || true + return "$why" + fi + status=0 + wait "$pid" || status=$? + return "$status" +} + for task in "$plan"/[0-9][0-9]-*.md; do name=$(basename "$task" .md) number=$(echo "$name" | cut -c1-2) @@ -43,14 +95,23 @@ for task in "$plan"/[0-9][0-9]-*.md; do fi before=$(git rev-parse HEAD) + : > "$runs/$name.log" echo "=== $milestone/$name: started $(date '+%H:%M:%S')" # A new `opencode run` is a new session: nothing carries over from the task before. - # shellcheck disable=SC2086 - if ! opencode run --agent implement -m "$model" ${OPENCODE_FLAGS:-} --title "$milestone/$name" "$prompt" \ - > "$runs/$name.log" 2>&1; then - echo "run-plan: opencode exited with an error in $name; see $runs/$name.log" >&2 - exit 1 + rc=0 + run_session "$prompt" || rc=$? + if [ "$rc" = 0 ] && [ "${RESUME:-0}" = 1 ] && [ "$(git rev-parse HEAD)" = "$before" ] \ + && [ -n "$(git status --porcelain)" ]; then + echo "=== $milestone/$name: work left uncommitted; resuming once $(date '+%H:%M:%S')" + rc=0 + run_session "The previous session on \`$task\` left uncommitted work (see \`git diff\`). Do not start over. Finish the task, run the gate, commit, and write the log row. You are $agent: write $agent in the log row's Model column, and end the commit message with this line, exactly: $trailer" || rc=$? fi + case "$rc" in + 0) ;; + 124) echo "run-plan: $name ran past $limit minutes and was stopped; see $runs/$name.log" >&2; exit 1 ;; + 125) echo "run-plan: no llama slot was busy for $idle minutes in $name, so it was stopped; see $runs/$name.log" >&2; exit 1 ;; + *) echo "run-plan: opencode exited with status $rc in $name; see $runs/$name.log" >&2; exit 1 ;; + esac echo "=== $milestone/$name: session ended $(date '+%H:%M:%S')" # The task must have left exactly what AGENTS.md asks for: a commit carrying the trailer, a @@ -67,6 +128,11 @@ for task in "$plan"/[0-9][0-9]-*.md; do echo "run-plan: $name's commit lacks the line '$trailer'; stopping" >&2 exit 1 fi + if git show HEAD -- "$log" | grep -Eq "^\+\| *$milestone/$name *\|[^|]*\| *stopped *\|"; then + echo "run-plan: $name stopped and said why in its log row; stopping" >&2 + git show HEAD -- "$log" | grep -E "^\+\| *$milestone/$name *\|" | cut -c2- >&2 + exit 1 + fi if ! grep -Eq "^\| *$milestone/$name *\|[^|]*\| *done *\|" "$log"; then echo "run-plan: no 'done' row for $milestone/$name in $log; stopping" >&2 grep -E "^\| *$milestone/$name *\|" "$log" >&2 || true