Skip to content

Commit 66fd535

Browse files
endolinbotclaude
andcommitted
driver: per-lane journal worktrees, gamut workflow, claim filter
Three changes that together let the driver dispatch real upstream work. Per-lane journal worktrees. Each lane now operates in its own detached worktree under journal-worktrees/<lane>/ that shares the primary journal/'s .git database. Each worktree has its own .git/worktrees/<lane>/{HEAD,index}, so concurrent git mv / reset / rebase / commit across lanes no longer share an index — the bash-level shared-worktree race that mangled claim attempts is eliminated. ensure_lane_worktree at startup self-heals: if the per-lane worktree is missing it runs `git worktree add --detach` from the primary at origin/journal and propagates the bot identity from the primary's local git config. The primary journal/ remains a human-inspectable clone of kriskowal/garden:journal; lanes never touch it directly. /journal-worktrees/ is gitignored. /journal.git/ removed from gitignore — the local bare repo it referred to is no longer needed now that origin = git@github.com:kriskowal/garden.git. Gamut workflow. `verb: gamut` and `verb: pr-creation` both map to a new dispatch_gamut_workflow. Minimal Phase 2 body: at `initial` the lane escalates once via the gardener-inbox mechanism naming the PR and the lane, then advances to `in-flight`; in-flight it polls `gh pr view` each tick and reaches terminal when GitHub reports MERGED or CLOSED. Substantive next-stage advancement (builder → cleaner → barrister → fixer-loop → appellate → un-draft) is delegated to claude via the escalation; the driver provides the deterministic frame. Claim filter. try_claim_one now skips jobs whose `verb:` the driver does not implement (driver_handles_verb) and whose `eligible_roles:` list does not name `driver` (job_eligible_for_driver). Without this the first sync of a populated upstream inbox claimed the oldest steward-only jobs whose workflows the driver could not run, then stranded the claims in the claimed/ directory. Filtering at claim time leaves those jobs for the right consumer. post_job's exit-code propagation (already landed in dfd2d29) and sync_upstream_jobs (briefly added when the driver had a standalone local journal) net out of this diff — the driver now operates directly against a shared clone of kriskowal/garden:journal, so the sync routine was deleted, and the post_job fix was already in place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f3f0e32 commit 66fd535

2 files changed

Lines changed: 124 additions & 10 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Garden working trees managed outside main.
22
# These are separate git worktrees / bare clones; do not track them from main.
33
/journal/
4-
/journal.git/
4+
/journal-worktrees/
55
/worktrees/
66
/dispatches/
77

scripts/driver/driver.sh

Lines changed: 123 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ esac
4545
SCRIPT_PATH=$(cd "$(dirname "$0")" && pwd)
4646
DEFAULT_GARDEN_ROOT=$(cd "$SCRIPT_PATH/../.." && pwd)
4747
GARDEN_ROOT=${GARDEN_ROOT:-$DEFAULT_GARDEN_ROOT}
48-
GARDEN_JOURNAL=${GARDEN_JOURNAL:-$GARDEN_ROOT/journal}
4948
GARDEN_HOST=${GARDEN_HOST:-$(hostname -s)}
5049
DRIVER_WORKFLOW=${DRIVER_WORKFLOW:-}
5150
DRIVER_PR=${DRIVER_PR:-}
@@ -60,7 +59,43 @@ DRIVER_ONESHOT=${DRIVER_ONESHOT:-0}
6059
DRIVER_PR_FROM_ENV=$DRIVER_PR
6160
DRIVER_WORKFLOW_FROM_ENV=$DRIVER_WORKFLOW
6261

62+
# Per-lane journal worktree. The primary journal at GARDEN_ROOT/journal
63+
# holds the shared .git database. Each lane operates in its own
64+
# detached worktree under journal-worktrees/<lane>/ so concurrent
65+
# claim/complete/reset/rebase operations across lanes do not share an
66+
# index — each lane has its own .git/worktrees/<lane>/{HEAD,index},
67+
# and bash's filesystem-level race surface shrinks to just the shared
68+
# objects database, which git already serializes via the loose-object
69+
# write protocol.
70+
PRIMARY_JOURNAL="$GARDEN_ROOT/journal"
71+
GARDEN_JOURNAL=${GARDEN_JOURNAL:-$GARDEN_ROOT/journal-worktrees/$LANE}
72+
6373
test -d "$GARDEN_ROOT" || { echo "driver: GARDEN_ROOT not a directory: $GARDEN_ROOT" >&2; exit 1; }
74+
75+
# Self-heal the per-lane worktree on first start (or after the host
76+
# operator removes it). Created detached at origin/journal so the
77+
# lane begins from upstream's view; the bot identity propagates from
78+
# the primary worktree's local git config.
79+
ensure_lane_worktree() {
80+
if [ -d "$GARDEN_JOURNAL/.git" ] || [ -f "$GARDEN_JOURNAL/.git" ]; then
81+
return 0
82+
fi
83+
if [ ! -d "$PRIMARY_JOURNAL/.git" ]; then
84+
echo "driver: PRIMARY_JOURNAL not a git repository: $PRIMARY_JOURNAL" >&2
85+
echo "driver: clone kriskowal/garden:journal there before starting lanes" >&2
86+
return 1
87+
fi
88+
git -C "$PRIMARY_JOURNAL" fetch --quiet origin journal 2>/dev/null || true
89+
mkdir -p "$(dirname "$GARDEN_JOURNAL")"
90+
git -C "$PRIMARY_JOURNAL" worktree add --detach "$GARDEN_JOURNAL" origin/journal >/dev/null
91+
local n e
92+
n=$(git -C "$PRIMARY_JOURNAL" config --get user.name 2>/dev/null || echo "")
93+
e=$(git -C "$PRIMARY_JOURNAL" config --get user.email 2>/dev/null || echo "")
94+
[ -n "$n" ] && git -C "$GARDEN_JOURNAL" config user.name "$n"
95+
[ -n "$e" ] && git -C "$GARDEN_JOURNAL" config user.email "$e"
96+
}
97+
98+
ensure_lane_worktree || exit 1
6499
test -d "$GARDEN_JOURNAL" || { echo "driver: GARDEN_JOURNAL not a directory: $GARDEN_JOURNAL" >&2; exit 1; }
65100

66101
STATE_DIR="$GARDEN_JOURNAL/drivers/$GARDEN_HOST"
@@ -315,6 +350,47 @@ dispatch_design_only_pr_workflow() {
315350
esac
316351
}
317352

353+
# Run-the-gamut workflow. Drives a code PR through the full
354+
# PR-creation-flow chain (builder → cleaner → barrister → fixer-loop →
355+
# appellate → un-draft). Phase 2 implements only the entry and the
356+
# terminal predicates; the in-flight body delegates substantive next-
357+
# stage advancement to claude via escalate_to_claude, which lands a
358+
# gardener-inbox message naming the PR and the lane. The driver still
359+
# polls the PR each tick and reaches terminal when GitHub reports the
360+
# PR as MERGED or CLOSED.
361+
dispatch_gamut_workflow() {
362+
local state=$1
363+
local pr_json
364+
pr_json=$(get_pr_json)
365+
case "$state" in
366+
initial)
367+
# Acknowledge the claim by escalating once, then transition to
368+
# the long-polling in-flight state. The escalation lands one
369+
# gardener-inbox message naming the PR; subsequent ticks do not
370+
# re-escalate unless the PR's state changes unexpectedly.
371+
escalate_to_claude "gamut-bootstrap:${DRIVER_PR:-unknown}" >/dev/null 2>&1 || true
372+
echo "advance:in-flight:null"
373+
;;
374+
in-flight)
375+
# Terminal predicates: GitHub reports MERGED or CLOSED. Anything
376+
# else is "still in motion"; wait until the next tick.
377+
local pr_state
378+
pr_state=$(printf '%s' "$pr_json" | sed -n 's/.*"state":"\([^"]*\)".*/\1/p' | head -1)
379+
case "$pr_state" in
380+
MERGED) echo "terminal:merged" ;;
381+
CLOSED) echo "terminal:closed" ;;
382+
*) echo "wait" ;;
383+
esac
384+
;;
385+
merged|closed)
386+
echo "terminal:$state"
387+
;;
388+
*)
389+
echo "escalate:unknown-state-$state"
390+
;;
391+
esac
392+
}
393+
318394
# --- deterministic-step helpers ----------------------------------------
319395

320396
run_un_draft() {
@@ -537,15 +613,48 @@ parse_claimed_job_into_env() {
537613
fi
538614
}
539615

616+
# Workflows this driver knows how to dispatch. Jobs whose `verb:` is
617+
# outside this set are skipped at claim time and left for some other
618+
# eligible consumer (the steward, a future driver build) to claim. Add
619+
# verbs here as new dispatch_<verb>_workflow functions land.
620+
driver_handles_verb() {
621+
case "$1" in
622+
design-only-pr|gamut|pr-creation) return 0 ;;
623+
*) return 1 ;;
624+
esac
625+
}
626+
627+
# Returns 0 if the job's `eligible_roles:` list names `driver`. The
628+
# eligibility list is a top-level YAML field whose entries are indented
629+
# ` - <role>` lines; the producer asserts which consumer roles are
630+
# allowed to claim the job. A driver lane skips jobs that do not name
631+
# it.
632+
job_eligible_for_driver() {
633+
awk '
634+
/^[^[:space:]]/ { in_list=0 }
635+
/^eligible_roles:/ { in_list=1; next }
636+
in_list && /^ - / { sub(/^ - /, ""); roles[NR] = $0 }
637+
END {
638+
for (k in roles) if (roles[k] == "driver") { found=1; break }
639+
exit (found ? 0 : 1)
640+
}
641+
' "$1"
642+
}
643+
540644
# Race to claim one open job. Echoes the claimed path on success;
541645
# returns 1 if the inbox is empty or every visible job was lost to
542-
# another claimant.
646+
# another claimant. Filters out jobs whose `verb:` the driver does
647+
# not implement or whose `eligible_roles:` does not include `driver`,
648+
# so a lane only attempts claims it can actually drive.
543649
try_claim_one() {
544650
local open_dir="$GARDEN_JOURNAL/jobs/open"
545651
[ -d "$open_dir" ] || return 1
546-
local f rel claimed
652+
local f rel claimed verb
547653
for f in "$open_dir"/*.md; do
548654
[ -f "$f" ] || continue
655+
job_eligible_for_driver "$f" || continue
656+
verb=$(sed -n 's/^verb: //p' "$f" | head -1)
657+
driver_handles_verb "$verb" || continue
549658
rel="jobs/open/$(basename "$f")"
550659
claimed=$(GARDEN_ROLE=driver "$GARDEN_ROOT/skills/job-board/claim-job.sh" "$rel" 2>/dev/null) || continue
551660
[ "$claimed" = "lost-race" ] && continue
@@ -597,6 +706,10 @@ run_once() {
597706
# Claim-bound lanes (no DRIVER_PR at startup) acquire their PR and
598707
# workflow from the job board on each tick. Env-bound lanes skip the
599708
# board entirely and drive the single PR they were launched with.
709+
# The journal is a shared clone of kriskowal/garden:journal; each
710+
# claim/complete via skills/job-board/{claim,complete}-job.sh fetches
711+
# from origin before touching the inbox, so no separate sync step is
712+
# needed here.
600713
if [ -z "$DRIVER_PR_FROM_ENV" ]; then
601714
if ! ensure_claimed_job; then
602715
# Inbox is empty. Surface the wait in the state file so external
@@ -621,11 +734,10 @@ run_once() {
621734
design-only-pr)
622735
next_directive=$(dispatch_design_only_pr_workflow "$state")
623736
;;
624-
pr-creation)
625-
# Phase 2 stub: the PR-creation workflow lands in a sibling PR.
626-
echo "driver: workflow pr-creation not yet implemented in Phase 2" >&2
627-
escalate_to_claude "workflow-not-implemented:pr-creation" || true
628-
next_directive="wait"
737+
gamut|pr-creation)
738+
# The two verbs share the same minimal Phase 2 state machine: a
739+
# one-shot escalation at claim and a poll-until-terminal body.
740+
next_directive=$(dispatch_gamut_workflow "$state")
629741
;;
630742
*)
631743
echo "driver: unknown workflow: $DRIVER_WORKFLOW" >&2
@@ -692,7 +804,9 @@ run_once() {
692804
main() {
693805
# Env-bound lanes resolve their workflow once at startup. Claim-bound
694806
# lanes leave DRIVER_WORKFLOW unset and let ensure_claimed_job pick it
695-
# up per-job from each claimed frontmatter.
807+
# up per-job from each claimed frontmatter; the inbox sync happens
808+
# naturally inside the claim/complete scripts (each fetches origin
809+
# before touching the journal).
696810
if [ -n "$DRIVER_PR_FROM_ENV" ]; then
697811
select_workflow
698812
fi

0 commit comments

Comments
 (0)