run-plan: a session time limit, a stopped-row message, trial resume and watchdog
From the combined Ornith guidance (§6). Each session runs in its own process group and is stopped after SESSION_LIMIT_MIN minutes (default 90), killed by group so the cargo it started goes too, never with pkill (titan has no `timeout`, so the limit is plain shell). A task that logs `stopped` gets its own message with the row. Two guards for a trial run: RESUME=1 resumes a session once when it leaves work uncommitted, and SLOTS_URL stops a session when no llama slot has been busy for IDLE_LIMIT_MIN minutes (default 10). Tested against a stub opencode outside a throwaway repository: a clean task, a stopped row, a wrong trailer, an uncommitted session with and without RESUME, a hang past a 1-minute limit (its child process killed with it), and a session with no busy slot for 1 minute. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+76
-10
@@ -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: <model> <agent@wntrmute.dev>` 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: <model> <agent@wntrmute.dev>` 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/<milestone> [FIRST]}"
|
||||
@@ -19,6 +26,8 @@ first="${2:-00}"
|
||||
model="${OPENCODE_MODEL:-straylight/laguna-s-2.1}"
|
||||
agent="${model##*/}"
|
||||
trailer="Co-Authored-By: $agent <agent@wntrmute.dev>"
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user