@plainconceptsplatform/workflows 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/worker-env.js +2 -3
- package/loops/actions/apply-agent-output/action.yml +113 -108
- package/loops/actions/create-agent-issues/action.yml +113 -52
- package/loops/actions/housekeeping/action.yml +95 -1
- package/loops/actions/validate-merge-gate-output/action.yml +6 -1
- package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +9 -2
- package/loops/actions/validate-refine-output/validate-refine-output.sh +30 -1
- package/loops/actions/verify-refine-output/verify-refine-output.sh +17 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +224 -9
- package/loops/docs/diagrams.md +90 -12
- package/loops/scripts/compile-agent-workflows.mjs +76 -2
- package/loops/workflows/agent-apply-review.md +5 -0
- package/loops/workflows/agent-implement.md +43 -7
- package/loops/workflows/agent-merge-gate.md +136 -8
- package/loops/workflows/agent-refine.md +152 -5
- package/loops/workflows/work-router.yml +330 -2
- package/package.json +1 -1
|
@@ -53,6 +53,7 @@ env:
|
|
|
53
53
|
SPLIT_PARENT_PREFIX: "<!-- split-parent: "
|
|
54
54
|
SPLIT_THRESHOLD: "8"
|
|
55
55
|
MAX_SPLIT_CHILDREN: "6"
|
|
56
|
+
MIN_CHILD_POINTS: "3"
|
|
56
57
|
INCOMPLETE_COMMENT: "Automated refinement ended without an outcome. The refine label remains for a retry."
|
|
57
58
|
SAFE_OUTPUT_COMMENT_PREFIX: "Refinement update"
|
|
58
59
|
ISSUE_CONTEXT_PATH: /tmp/gh-aw/agent/issue-context.json
|
|
@@ -396,6 +397,7 @@ jobs:
|
|
|
396
397
|
update-issues: 'true'
|
|
397
398
|
create-issues: 'true'
|
|
398
399
|
fallback-issue-number: ${{ inputs.issue-number }}
|
|
400
|
+
parent-issue: ${{ needs.validate_output.outputs.outcome == 'split' && inputs.issue-number || '' }}
|
|
399
401
|
- name: Apply complete refinement labels
|
|
400
402
|
if: needs.validate_output.outputs.outcome == 'complete'
|
|
401
403
|
uses: ./.github/actions/add-issue-labels
|
|
@@ -434,6 +436,83 @@ jobs:
|
|
|
434
436
|
${{ env.REFINE_LABEL }}
|
|
435
437
|
${{ env.WORKING_LABEL }}
|
|
436
438
|
${{ env.REVIEW_LABEL }}
|
|
439
|
+
# The stack root pull request. Every output in this worker is staged and applied by this
|
|
440
|
+
# job, and gh-aw's handler opens nothing while the run is staged: a per-output
|
|
441
|
+
# `staged: false` on create-pull-request compiles into the handler config and is ignored
|
|
442
|
+
# there (Lyceum #45, run 38740216266: "Pull request creation preview written to step
|
|
443
|
+
# summary"). So the root is opened here, from the agent's own create_pull_request request,
|
|
444
|
+
# once the validator has called the run a split. Refine writes no code, so the root carries
|
|
445
|
+
# none: an empty commit on the default branch's tip, created through the API, which is what
|
|
446
|
+
# gh-aw's allow-empty would have pushed. Idempotent: an open root on the feature branch is
|
|
447
|
+
# reused and an existing branch is not recreated, so a retry stamps the same root.
|
|
448
|
+
- name: Open the stack root pull request
|
|
449
|
+
id: root
|
|
450
|
+
if: needs.validate_output.outputs.outcome == 'split'
|
|
451
|
+
continue-on-error: true
|
|
452
|
+
env:
|
|
453
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
454
|
+
REPO: ${{ github.repository }}
|
|
455
|
+
ISSUE: ${{ inputs.issue-number }}
|
|
456
|
+
AGENT_OUTPUT: agent-artifact/agent_output.json
|
|
457
|
+
run: |
|
|
458
|
+
set -euo pipefail
|
|
459
|
+
branch="feat/${ISSUE}"
|
|
460
|
+
item=$(jq -c '[.items[] | select(.type == "create_pull_request")][0] // {}' "$AGENT_OUTPUT")
|
|
461
|
+
title=$(printf '%s' "$item" | jq -r '.title // empty')
|
|
462
|
+
body=$(printf '%s' "$item" | jq -r '.body // empty')
|
|
463
|
+
if [ -z "$title" ]; then
|
|
464
|
+
title=$(gh issue view "$ISSUE" --repo "$REPO" --json title --jq '.title')
|
|
465
|
+
fi
|
|
466
|
+
case "$title" in "[bot] "*) ;; *) title="[bot] ${title}" ;; esac
|
|
467
|
+
# The root's merge is what ends the feature, so it must close the feature issue.
|
|
468
|
+
if ! printf '%s' "$body" | grep -qiE "(fixes|closes|resolves) #${ISSUE}([^0-9]|$)"; then
|
|
469
|
+
body=$(printf '%s\n\nFixes #%s' "$body" "$ISSUE")
|
|
470
|
+
fi
|
|
471
|
+
|
|
472
|
+
existing=$(gh pr list --repo "$REPO" --state open --head "$branch" --json number --jq '.[0].number // empty')
|
|
473
|
+
if [ -n "$existing" ]; then
|
|
474
|
+
echo "Reusing open root #${existing} on ${branch}"
|
|
475
|
+
echo "pr_number=${existing}" >> "$GITHUB_OUTPUT"
|
|
476
|
+
exit 0
|
|
477
|
+
fi
|
|
478
|
+
|
|
479
|
+
default=$(gh api "repos/${REPO}" --jq '.default_branch')
|
|
480
|
+
if ! gh api "repos/${REPO}/git/ref/heads/${branch}" >/dev/null 2>&1; then
|
|
481
|
+
base_sha=$(gh api "repos/${REPO}/git/ref/heads/${default}" --jq '.object.sha')
|
|
482
|
+
tree=$(gh api "repos/${REPO}/git/commits/${base_sha}" --jq '.tree.sha')
|
|
483
|
+
commit=$(gh api "repos/${REPO}/git/commits" -f message="chore: open the stack root for #${ISSUE}" \
|
|
484
|
+
-f tree="$tree" -f "parents[]=${base_sha}" --jq '.sha')
|
|
485
|
+
gh api "repos/${REPO}/git/refs" -f ref="refs/heads/${branch}" -f sha="$commit" >/dev/null
|
|
486
|
+
fi
|
|
487
|
+
printf '%s' "$body" > /tmp/root-body.md
|
|
488
|
+
url=$(gh pr create --repo "$REPO" --base "$default" --head "$branch" --title "$title" --body-file /tmp/root-body.md)
|
|
489
|
+
echo "Opened root ${url} on ${branch}"
|
|
490
|
+
echo "pr_number=${url##*/}" >> "$GITHUB_OUTPUT"
|
|
491
|
+
# The stack markers. The feature branch and its root pull request are what every child
|
|
492
|
+
# builds on and what completion is counted against, so they are recorded on the feature
|
|
493
|
+
# issue itself, where the child implement runs and the router read them back with no new
|
|
494
|
+
# service: `<!-- stack-root-branch: ... -->` resolves each child's dispatch ref, and
|
|
495
|
+
# `<!-- stack-root-pr: ... -->` names the held root. The branch comes from the pull
|
|
496
|
+
# request itself, never from the name the agent chose, because what reached the remote
|
|
497
|
+
# is the only truth. Idempotent: old markers are replaced, so a re-split after a
|
|
498
|
+
# discarded first attempt re-stamps cleanly. Missing markers fail closed -- a child
|
|
499
|
+
# without them takes the flat path, never a silent feature-branch dispatch.
|
|
500
|
+
- name: Record the stack root on the issue
|
|
501
|
+
if: needs.validate_output.outputs.outcome == 'split' && (steps.root.outputs.pr_number != '' || needs.safe_outputs.outputs.created_pr_number != '')
|
|
502
|
+
continue-on-error: true
|
|
503
|
+
env:
|
|
504
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
505
|
+
REPO: ${{ github.repository }}
|
|
506
|
+
ISSUE: ${{ inputs.issue-number }}
|
|
507
|
+
PR_NUMBER: ${{ steps.root.outputs.pr_number || needs.safe_outputs.outputs.created_pr_number }}
|
|
508
|
+
run: |
|
|
509
|
+
set -euo pipefail
|
|
510
|
+
branch=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefName --jq '.headRefName')
|
|
511
|
+
body=$(gh issue view "$ISSUE" --repo "$REPO" --json body --jq '.body // ""')
|
|
512
|
+
cleaned=$(printf '%s' "$body" | sed -E 's/<!-- stack-root-(branch|pr): [^ ]+ -->//g' | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
|
|
513
|
+
printf '%s\n\n<!-- stack-root-branch: %s -->\n<!-- stack-root-pr: %s -->' "$cleaned" "$branch" "$PR_NUMBER" > /tmp/issue-body.md
|
|
514
|
+
gh issue edit "$ISSUE" --repo "$REPO" --body-file /tmp/issue-body.md
|
|
515
|
+
echo "Stamped stack root branch $branch and PR #$PR_NUMBER on issue #$ISSUE"
|
|
437
516
|
# Estimates become labels here rather than in the agent, because labels are workflow-owned
|
|
438
517
|
# state. Children were created moments ago by the same run, so they are labelled together
|
|
439
518
|
# with the parent: each body carries its own marker.
|
|
@@ -507,6 +586,7 @@ jobs:
|
|
|
507
586
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
508
587
|
REPO: ${{ github.repository }}
|
|
509
588
|
PARENT: ${{ inputs.issue-number }}
|
|
589
|
+
ROOT_PR: ${{ steps.root.outputs.pr_number || needs.safe_outputs.outputs.created_pr_number }}
|
|
510
590
|
REFINE_LABEL: ${{ env.REFINE_LABEL }}
|
|
511
591
|
REFINED_LABEL: ${{ env.REFINED_LABEL }}
|
|
512
592
|
IMPLEMENT_LABEL: ${{ env.IMPLEMENT_LABEL }}
|
|
@@ -518,6 +598,21 @@ jobs:
|
|
|
518
598
|
children=$(gh issue list --repo "$REPO" --state open --limit 50 \
|
|
519
599
|
--search "\"<!-- split-parent: ${PARENT} -->\" in:body" --json number --jq '.[].number' || true)
|
|
520
600
|
fi
|
|
601
|
+
|
|
602
|
+
# Each child body also carries the resolved feature branch. The implement agent sees
|
|
603
|
+
# only the child issue's body in its context, never the parent's, so a marker that
|
|
604
|
+
# lives on the parent alone is invisible to the very prompt that must act on it.
|
|
605
|
+
# Stamping the branch the root pull request really runs on (headRefName, the same
|
|
606
|
+
# source of truth the parent marker uses) makes the child self-describing end to
|
|
607
|
+
# end: the router reads it to resolve the dispatch ref and the agent reads it to set
|
|
608
|
+
# its pull request base. Idempotent, and skipped entirely when the root pull request
|
|
609
|
+
# was not created -- without a feature branch there is no stack to describe, and the
|
|
610
|
+
# child simply takes the flat path.
|
|
611
|
+
STACK_BRANCH=''
|
|
612
|
+
if [ -n "$ROOT_PR" ]; then
|
|
613
|
+
STACK_BRANCH=$(gh pr view "$ROOT_PR" --repo "$REPO" --json headRefName --jq '.headRefName' 2>/dev/null || true)
|
|
614
|
+
fi
|
|
615
|
+
|
|
521
616
|
for child in $children; do
|
|
522
617
|
[ "$child" = "$PARENT" ] && continue
|
|
523
618
|
# A child handed over earlier carries the refined label. Handing it again would start
|
|
@@ -527,6 +622,12 @@ jobs:
|
|
|
527
622
|
echo "#$child was already handed over; leaving it alone"
|
|
528
623
|
continue
|
|
529
624
|
fi
|
|
625
|
+
if [ -n "$STACK_BRANCH" ]; then
|
|
626
|
+
child_body=$(gh issue view "$child" --repo "$REPO" --json body --jq '.body // ""')
|
|
627
|
+
cleaned=$(printf '%s' "$child_body" | sed -E 's/<!-- stack-root-branch: [^ ]+ -->//g' | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
|
|
628
|
+
printf '%s\n\n<!-- stack-root-branch: %s -->' "$cleaned" "$STACK_BRANCH" > /tmp/child-body.md
|
|
629
|
+
gh issue edit "$child" --repo "$REPO" --body-file /tmp/child-body.md >/dev/null
|
|
630
|
+
fi
|
|
530
631
|
gh issue edit "$child" --repo "$REPO" \
|
|
531
632
|
--remove-label "$REFINE_LABEL" --remove-label "$IMPLEMENT_LABEL" >/dev/null 2>&1 || true
|
|
532
633
|
gh issue edit "$child" --repo "$REPO" --add-label "$REFINED_LABEL" >/dev/null
|
|
@@ -744,6 +845,23 @@ safe-outputs:
|
|
|
744
845
|
# one issue nobody can land; the cap stops a runaway decomposition.
|
|
745
846
|
create-issue:
|
|
746
847
|
max: 6
|
|
848
|
+
# The stack root. A split story integrates on a feature branch instead of the default
|
|
849
|
+
# branch: the children land there one by one and the root pull request carries the whole
|
|
850
|
+
# feature to the default branch once they are done, which is also the human validation
|
|
851
|
+
# point before main. The branch name is preserved rather than salted because it is a
|
|
852
|
+
# long-lived integration line that children, markers and prompts all address by name;
|
|
853
|
+
# a collision with an existing branch fails the push loudly, which is the correct
|
|
854
|
+
# outcome for a durable ref. The allowed-branches glob must stay in sync with the name
|
|
855
|
+
# the prompt tells the agent to choose (feat/ plus the parent issue number). Only the
|
|
856
|
+
# root targets the default branch, so allowed-base-branches admits nothing else.
|
|
857
|
+
create-pull-request:
|
|
858
|
+
# Staged like everything else, and opened by the `Open the stack root pull request` step:
|
|
859
|
+
# gh-aw ignores a per-output `staged: false` while the worker is staged.
|
|
860
|
+
allowed-branches: ["feat/*"]
|
|
861
|
+
allowed-base-branches: ["main"]
|
|
862
|
+
preserve-branch-name: true
|
|
863
|
+
title-prefix: "[bot] "
|
|
864
|
+
allow-empty: true
|
|
747
865
|
|
|
748
866
|
|
|
749
867
|
# The fleet is two machines, so this clock is also how long a stuck run can hold half of it.
|
|
@@ -820,11 +938,24 @@ timeout-minutes: 90
|
|
|
820
938
|
**If it splits:** write between two and ${{ env.MAX_SPLIT_CHILDREN }} children. Each child is
|
|
821
939
|
a complete refined story wrapped in the same issue form, with its own
|
|
822
940
|
acceptance criteria, its own tests section, and its own estimate of 5 or less. Never write a
|
|
823
|
-
child estimated
|
|
941
|
+
child estimated below ${{ env.MIN_CHILD_POINTS }}: that is a fragment, not a story -- the whole
|
|
942
|
+
stack exists to carry real slices, so fold anything smaller into a sibling and give the
|
|
943
|
+
sibling the honest combined estimate. Call `create_issue` once
|
|
824
944
|
per child, and in each child body include:
|
|
825
945
|
|
|
826
946
|
- the line `${{ env.SPLIT_PARENT_PREFIX }}N -->` naming the parent issue number
|
|
827
|
-
- a `Blocked by #
|
|
947
|
+
- a `Blocked by #aw_<id>` line for each sibling that must land first, when order genuinely
|
|
948
|
+
matters, one line per sibling. Siblings have no numbers yet, so give every child a
|
|
949
|
+
`temporary_id` of `aw_` plus 3 to 12 letters, digits or underscores (`aw_modules`, not
|
|
950
|
+
`aw_module_backend`, which gh-aw rejects as too long) and name siblings by it; the workflow rewrites each
|
|
951
|
+
`#aw_<id>` to the real issue number once the children exist. Write dependencies only as these
|
|
952
|
+
lines and never set the `blocked_by` field: it takes one reference, and a value naming two
|
|
953
|
+
rejected the item and discarded the whole refinement (Lyceum #45).
|
|
954
|
+
- a `Parent feature branch: feat/<parent issue number>` line (for parent #123,
|
|
955
|
+
`Parent feature branch: feat/123`) naming the branch the whole split integrates on:
|
|
956
|
+
each child's pull request targets that branch, never the default branch, the children
|
|
957
|
+
land there one at a time, and only the root pull request carries the finished feature
|
|
958
|
+
to the default branch
|
|
828
959
|
|
|
829
960
|
Then call `update_issue` on the parent, replacing its body with a short summary of the whole
|
|
830
961
|
piece of work, the reason it was split, and a checklist linking every child. The parent keeps
|
|
@@ -914,7 +1045,23 @@ timeout-minutes: 90
|
|
|
914
1045
|
|
|
915
1046
|
**The story was split.** You estimated ${{ env.SPLIT_THRESHOLD }} or more and found real
|
|
916
1047
|
seams. Call `create_issue` once per child, then `update_issue` on the parent with the
|
|
917
|
-
summary and the checklist
|
|
1048
|
+
summary and the checklist. Then open the stack's root pull request, in this order:
|
|
1049
|
+
|
|
1050
|
+
First write the feature's journey as one failing end-to-end test, following the
|
|
1051
|
+
repository's own end-to-end test conventions. That failing test is the specification, not
|
|
1052
|
+
the implementation: write test code only, never the production code that would make it
|
|
1053
|
+
pass. When the repository has no end-to-end test conventions at all, skip the test rather
|
|
1054
|
+
than inventing one -- the root pull request may be proposal-only.
|
|
1055
|
+
|
|
1056
|
+
Then call `create_pull_request` once, with the branch named `feat/` plus the parent issue
|
|
1057
|
+
number -- exactly the name you wrote into every child's `Parent feature branch:` line --
|
|
1058
|
+
a one-line title for the whole feature, and a body carrying that same summary and child
|
|
1059
|
+
checklist plus the closing line `Fixes #${{ inputs.issue-number }}`. Do not pass `base`:
|
|
1060
|
+
the root pull request targets the default branch, and its closing line fires only when it
|
|
1061
|
+
eventually merges there, after every child has landed on the feature branch.
|
|
1062
|
+
|
|
1063
|
+
Finally, call `add_comment` with `${{ env.REFINE_MARKER }}`, then
|
|
918
1064
|
`${{ env.SAFE_OUTPUT_COMMENT_PREFIX }}`, then one sentence naming the estimate you gave the
|
|
919
|
-
|
|
920
|
-
|
|
1065
|
+
whole, how many children you wrote, and the feature branch they integrate on. The children
|
|
1066
|
+
carry the work forward; the parent stays open as their tracker and is never implemented
|
|
1067
|
+
directly.
|
|
@@ -461,9 +461,160 @@ jobs:
|
|
|
461
461
|
echo "has-open-pr=false" >> "$GITHUB_OUTPUT"
|
|
462
462
|
fi
|
|
463
463
|
|
|
464
|
+
# A stack child implements on the feature branch its parent issue names. The agent sandbox
|
|
465
|
+
# cannot resolve that branch itself -- agents have no git credentials and checkout a pinned
|
|
466
|
+
# default-branch SHA -- so the router reads the marker and resolves the dispatch ref before
|
|
467
|
+
# the worker starts. Refine stamps `<!-- stack-root-branch: feat/N -->` both on the PARENT
|
|
468
|
+
# feature issue and on every child body at hand-over, so the child is self-describing for
|
|
469
|
+
# the implement prompt, which reads only the child's context. The split-parent chain is the
|
|
470
|
+
# fallback when a child predates the stamp (retries of issues split by an earlier refine).
|
|
471
|
+
# Missing, empty or garbled markers fail closed: the child takes the flat call-implement
|
|
472
|
+
# path and lands on the default branch, exactly what a repo without stacks does today.
|
|
473
|
+
# The in-flight check exists because a dispatched agent-implement run is invisible to
|
|
474
|
+
# check-implement-pr's older-run dupe check: this router run completes as soon as
|
|
475
|
+
# `gh workflow run` returns (fire-and-forget), while a workflow_call caller stays
|
|
476
|
+
# in_progress -- and the dispatch run's title "Working (Implement)" and the App-token
|
|
477
|
+
# label events' actor "app/..." both miss the title conditions that check matches.
|
|
478
|
+
# head_branch is the discriminator: a dispatched implement's run records the dispatch ref,
|
|
479
|
+
# feat/N, while a workflow_call run's head_branch is the default branch. Filtering
|
|
480
|
+
# in_progress/queued agent-implement.lock.yml runs by head_branch therefore isolates stack
|
|
481
|
+
# implements with zero effect on flat runs.
|
|
482
|
+
check-stack-branch:
|
|
483
|
+
needs: [classify, authorize]
|
|
484
|
+
if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true'
|
|
485
|
+
runs-on: agents-arc
|
|
486
|
+
timeout-minutes: 3
|
|
487
|
+
permissions:
|
|
488
|
+
contents: read
|
|
489
|
+
issues: write
|
|
490
|
+
actions: read
|
|
491
|
+
outputs:
|
|
492
|
+
is-stack: ${{ steps.stack.outputs.is-stack }}
|
|
493
|
+
stack-branch: ${{ steps.stack.outputs.stack-branch }}
|
|
494
|
+
stack-in-flight: ${{ steps.inflight.outputs.stack-in-flight }}
|
|
495
|
+
stack-blocked: ${{ steps.blocked.outputs.stack-blocked }}
|
|
496
|
+
steps:
|
|
497
|
+
- name: Resolve the feature branch from the stack markers
|
|
498
|
+
id: stack
|
|
499
|
+
env:
|
|
500
|
+
GH_TOKEN: ${{ github.token }}
|
|
501
|
+
REPO: ${{ github.repository }}
|
|
502
|
+
ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
|
|
503
|
+
run: |
|
|
504
|
+
set -euo pipefail
|
|
505
|
+
echo "is-stack=false" >> "$GITHUB_OUTPUT"
|
|
506
|
+
echo "stack-branch=" >> "$GITHUB_OUTPUT"
|
|
507
|
+
|
|
508
|
+
# Read failures degrade to the flat path, never to a failed job: call-implement
|
|
509
|
+
# names this job in its needs, so a failure here would block a perfectly ordinary
|
|
510
|
+
# flat implement run. A repo without stack markers must behave exactly as before.
|
|
511
|
+
body=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')
|
|
512
|
+
# Marker-first: the child may carry its own resolved marker from an earlier hop.
|
|
513
|
+
# `|| true` on both pipelines: under `set -o pipefail`, grep's exit 1 on no match
|
|
514
|
+
# (the common flat case) or head's SIGPIPE on a multi-marker body must not fail
|
|
515
|
+
# the job -- call-implement needs this job, so a failure here would block flat
|
|
516
|
+
# implements too.
|
|
517
|
+
branch=$(printf '%s' "$body" | grep -oE '<!-- stack-root-branch: [^ ]+ -->' | head -1 | sed -E 's/<!-- stack-root-branch: ([^ ]+) -->/\1/' || true)
|
|
518
|
+
|
|
519
|
+
if [ -z "$branch" ]; then
|
|
520
|
+
# No marker on the child: follow the split-parent chain to the feature issue,
|
|
521
|
+
# where refine stamps the stack root.
|
|
522
|
+
parent=$(printf '%s' "$body" \
|
|
523
|
+
| grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 \
|
|
524
|
+
| grep -oE '[0-9]+' || true)
|
|
525
|
+
if [ -n "$parent" ]; then
|
|
526
|
+
parent_body=$(gh issue view "$parent" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')
|
|
527
|
+
branch=$(printf '%s' "$parent_body" \
|
|
528
|
+
| grep -oE '<!-- stack-root-branch: [^ ]+ -->' | head -1 \
|
|
529
|
+
| sed -E 's/<!-- stack-root-branch: ([^ ]+) -->/\1/' || true)
|
|
530
|
+
fi
|
|
531
|
+
fi
|
|
532
|
+
|
|
533
|
+
# Fail closed on anything unreadable or unsafe: a missing marker, a garbled
|
|
534
|
+
# split parent, or a marker naming a deleted branch all take the flat path.
|
|
535
|
+
if ! printf '%s' "$branch" | grep -qE '^[A-Za-z0-9._/-]+$'; then
|
|
536
|
+
echo "::notice::Issue #$ISSUE_NUMBER carries no readable stack-root-branch marker; taking the flat path."
|
|
537
|
+
exit 0
|
|
538
|
+
fi
|
|
539
|
+
if ! gh api "repos/$REPO/branches/$(printf '%s' "$branch" | sed 's|/|%2F|g')" --jq '.name' >/dev/null 2>&1; then
|
|
540
|
+
echo "::notice::Stack root branch $branch no longer exists on the remote; taking the flat path."
|
|
541
|
+
echo "stack-branch=$branch" >> "$GITHUB_OUTPUT"
|
|
542
|
+
exit 0
|
|
543
|
+
fi
|
|
544
|
+
|
|
545
|
+
echo "is-stack=true" >> "$GITHUB_OUTPUT"
|
|
546
|
+
echo "stack-branch=$branch" >> "$GITHUB_OUTPUT"
|
|
547
|
+
|
|
548
|
+
# A child whose `Blocked by #N` sibling has not landed is held, not dispatched: dispatched now
|
|
549
|
+
# it would cut from a feature branch that lacks the sibling's work. Lyceum #112 (units,
|
|
550
|
+
# blocked by #111, modules) was dispatched alongside #111 and would have built units on a
|
|
551
|
+
# feat/45 with no modules. The in-flight check below cannot catch that: both routers ran
|
|
552
|
+
# before either dispatch existed, and serialising runs would still start #112 before #111's
|
|
553
|
+
# pull request merged. The landing of the blocker releases the child (the stack block in
|
|
554
|
+
# detect-pr-conflicts). Held, it keeps `implement` and loses `bot-working`, so it reads as
|
|
555
|
+
# waiting rather than stuck.
|
|
556
|
+
- name: Hold a child whose blockers have not landed
|
|
557
|
+
id: blocked
|
|
558
|
+
if: steps.stack.outputs.is-stack == 'true'
|
|
559
|
+
env:
|
|
560
|
+
GH_TOKEN: ${{ github.token }}
|
|
561
|
+
REPO: ${{ github.repository }}
|
|
562
|
+
ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
|
|
563
|
+
STACK_BRANCH: ${{ steps.stack.outputs.stack-branch }}
|
|
564
|
+
run: |
|
|
565
|
+
set -euo pipefail
|
|
566
|
+
echo "stack-blocked=false" >> "$GITHUB_OUTPUT"
|
|
567
|
+
body=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')
|
|
568
|
+
waiting=""
|
|
569
|
+
for b in $(printf '%s' "$body" | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true); do
|
|
570
|
+
gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open || continue
|
|
571
|
+
# Open, but landed if a pull request that closes it has merged into the feature branch:
|
|
572
|
+
# the release fires on that merge and the gate closes the issue a moment later, or not
|
|
573
|
+
# at all when it fails. Reading the issue state alone would hold the child again with
|
|
574
|
+
# no later landing left to release it.
|
|
575
|
+
landed=$(gh pr list --repo "$REPO" --state merged --base "$STACK_BRANCH" --search "#$b in:body" --json body --jq "[.[] | select(.body | test(\"(?i)(close[sd]?|fix(e[sd])?|resolve[sd]?) #${b}([^0-9]|$)\"))] | length" 2>/dev/null || echo 0)
|
|
576
|
+
[ "${landed:-0}" -gt 0 ] && continue
|
|
577
|
+
waiting="$waiting #$b"
|
|
578
|
+
done
|
|
579
|
+
[ -n "$waiting" ] || exit 0
|
|
580
|
+
echo "::notice::#$ISSUE_NUMBER is blocked by$waiting, not yet landed on $STACK_BRANCH; holding it."
|
|
581
|
+
echo "stack-blocked=true" >> "$GITHUB_OUTPUT"
|
|
582
|
+
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label bot-working >/dev/null 2>&1 || true
|
|
583
|
+
marker='<!-- stack-held -->'
|
|
584
|
+
if ! gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments?per_page=100" --jq '.[].body' 2>/dev/null | grep -qF "$marker"; then
|
|
585
|
+
{
|
|
586
|
+
printf '%s\n' "$marker"
|
|
587
|
+
printf "Held until%s have landed on \`%s\`: this child builds on them. It keeps \`implement\` and is released when they merge.\n" "$waiting" "$STACK_BRANCH"
|
|
588
|
+
} > /tmp/held.md
|
|
589
|
+
gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body-file /tmp/held.md >/dev/null || true
|
|
590
|
+
fi
|
|
591
|
+
|
|
592
|
+
- name: Detect an in-flight implement on the feature branch
|
|
593
|
+
id: inflight
|
|
594
|
+
if: steps.stack.outputs.is-stack == 'true'
|
|
595
|
+
env:
|
|
596
|
+
GH_TOKEN: ${{ github.token }}
|
|
597
|
+
REPO: ${{ github.repository }}
|
|
598
|
+
STACK_BRANCH: ${{ steps.stack.outputs.stack-branch }}
|
|
599
|
+
run: |
|
|
600
|
+
set -euo pipefail
|
|
601
|
+
echo "stack-in-flight=false" >> "$GITHUB_OUTPUT"
|
|
602
|
+
# Only dispatched stack implements run with head_branch = feat/N; a workflow_call
|
|
603
|
+
# (flat) implement run's head_branch is the default branch, so this filter never
|
|
604
|
+
# sees it. This closes the dupe-visibility gap left by fire-and-forget dispatch.
|
|
605
|
+
for status in in_progress queued; do
|
|
606
|
+
live=$(gh api "repos/$REPO/actions/workflows/agent-implement.lock.yml/runs?status=$status&per_page=100" \
|
|
607
|
+
--jq "[.workflow_runs[] | select(.head_branch == \"${STACK_BRANCH}\")] | length" 2>/dev/null || echo 0)
|
|
608
|
+
if [ "${live:-0}" -gt 0 ]; then
|
|
609
|
+
echo "::notice::An implement run is already in flight on $STACK_BRANCH; skipping dispatch."
|
|
610
|
+
echo "stack-in-flight=true" >> "$GITHUB_OUTPUT"
|
|
611
|
+
exit 0
|
|
612
|
+
fi
|
|
613
|
+
done
|
|
614
|
+
|
|
464
615
|
call-implement:
|
|
465
|
-
needs: [classify, authorize, check-implement-pr]
|
|
466
|
-
if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true'
|
|
616
|
+
needs: [classify, authorize, check-implement-pr, check-stack-branch]
|
|
617
|
+
if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true' && needs.check-stack-branch.outputs.is-stack != 'true'
|
|
467
618
|
uses: ./.github/workflows/agent-implement.lock.yml
|
|
468
619
|
concurrency:
|
|
469
620
|
group: implement-global
|
|
@@ -483,6 +634,50 @@ jobs:
|
|
|
483
634
|
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
484
635
|
AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
|
|
485
636
|
|
|
637
|
+
# Stack children dispatch agent-implement by `gh workflow run --ref feat/N` instead of calling
|
|
638
|
+
# it: a workflow_call would pin the callee to THIS event's SHA, which for a label event is
|
|
639
|
+
# the default branch, not the feature branch. Dispatch on the feature ref makes the callee's
|
|
640
|
+
# GITHUB_SHA the feature tip, so the agent patches exactly the tree its PR will target, and
|
|
641
|
+
# the compile-time checkout ternary keys off `workflow_dispatch` to checkout github.ref_name.
|
|
642
|
+
# The dispatch itself carries no secrets (gh workflow run cannot forward them); the
|
|
643
|
+
# dispatched run reads REPO-LEVEL OPENAI_API_KEY / CODEX_API_KEY / COPILOT_GITHUB_TOKEN, the
|
|
644
|
+
# stack-activation prerequisite documented alongside the markers. BOT_APP_ID / BOT_PRIVATE_KEY
|
|
645
|
+
# are already repo-level. dispatch-triage proves the pattern: same app-token mint, same
|
|
646
|
+
# `actions: write` scope needed for `gh workflow run`.
|
|
647
|
+
dispatch-implement:
|
|
648
|
+
needs: [classify, authorize, check-implement-pr, check-stack-branch]
|
|
649
|
+
if: >
|
|
650
|
+
needs.classify.outputs.route == 'implement' &&
|
|
651
|
+
needs.authorize.outputs.trusted == 'true' &&
|
|
652
|
+
needs.check-implement-pr.outputs.has-open-pr != 'true' &&
|
|
653
|
+
needs.check-stack-branch.outputs.is-stack == 'true' &&
|
|
654
|
+
needs.check-stack-branch.outputs.stack-in-flight != 'true' &&
|
|
655
|
+
needs.check-stack-branch.outputs.stack-blocked != 'true'
|
|
656
|
+
runs-on: agents-arc
|
|
657
|
+
timeout-minutes: 5
|
|
658
|
+
permissions:
|
|
659
|
+
actions: write
|
|
660
|
+
contents: read
|
|
661
|
+
steps:
|
|
662
|
+
- name: Create bot token
|
|
663
|
+
id: app-token
|
|
664
|
+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
665
|
+
with:
|
|
666
|
+
client-id: ${{ secrets.BOT_APP_ID }}
|
|
667
|
+
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
668
|
+
- name: Dispatch stack implement on the feature branch
|
|
669
|
+
env:
|
|
670
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
671
|
+
REPO: ${{ github.repository }}
|
|
672
|
+
STACK_BRANCH: ${{ needs.check-stack-branch.outputs.stack-branch }}
|
|
673
|
+
ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
|
|
674
|
+
ATTEMPTS: ${{ needs.classify.outputs.attempts }}
|
|
675
|
+
run: |
|
|
676
|
+
set -euo pipefail
|
|
677
|
+
gh workflow run agent-implement.lock.yml --repo "$REPO" --ref "$STACK_BRANCH" \
|
|
678
|
+
-f issue-number="$ISSUE_NUMBER" \
|
|
679
|
+
-f attempts_so_far="$ATTEMPTS"
|
|
680
|
+
|
|
486
681
|
dispatch-triage:
|
|
487
682
|
needs: [classify, authorize]
|
|
488
683
|
if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.is_outside_collaborator == 'true'
|
|
@@ -973,6 +1168,139 @@ jobs:
|
|
|
973
1168
|
while IFS= read -r pr; do
|
|
974
1169
|
maybe_dispatch "$pr"
|
|
975
1170
|
done
|
|
1171
|
+
|
|
1172
|
+
# Stacked delivery: the merged pull request merged into a feature branch, not the
|
|
1173
|
+
# default branch, so this is a child landing. If the feature branch has no open
|
|
1174
|
+
# children left and its root pull request is green, the stack is complete and a
|
|
1175
|
+
# comment on the root pull request tells the human it is ready for the root merge.
|
|
1176
|
+
# Repos without stack markers never reach the interesting part: the event PR's base
|
|
1177
|
+
# was the default branch, so the guard below fails immediately. Fully idempotent:
|
|
1178
|
+
# the completion comment carries its own marker, and a run that finds it already
|
|
1179
|
+
# present exits without writing anything.
|
|
1180
|
+
event_base=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.base.ref // empty' 2>/dev/null || echo '')
|
|
1181
|
+
if [ -n "$event_base" ] && [ "$event_base" != "$DEFAULT_BRANCH" ]; then
|
|
1182
|
+
echo "PR #$EVENT_PR merged into a feature branch; checking the stack."
|
|
1183
|
+
# The implement worker's pull request closes exactly its own child issue, so
|
|
1184
|
+
# the closed issue comes from the linked closing references (the resolved form
|
|
1185
|
+
# GitHub computes whatever keyword the agent wrote), with the body text as
|
|
1186
|
+
# fallback for a PR created before that guarantee.
|
|
1187
|
+
closed_issue=$(gh pr view "$EVENT_PR" --repo "$REPO" --json closingIssuesReferences \
|
|
1188
|
+
--jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
|
|
1189
|
+
[ -n "$closed_issue" ] || closed_issue=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.body // ""' \
|
|
1190
|
+
| grep -oiE '(closes?|fixes?|resolves?) #[0-9]+' | head -1 | grep -oE '[0-9]+' || true)
|
|
1191
|
+
if [ -z "$closed_issue" ]; then
|
|
1192
|
+
echo "PR #$EVENT_PR closes no issue; not a stack child. Nothing to do."
|
|
1193
|
+
else
|
|
1194
|
+
# Refine stamps the split-parent marker on every child body it hands over, so
|
|
1195
|
+
# the child's own body names the feature issue. The reverse sub-issues
|
|
1196
|
+
# listing has no equivalent per-child lookup: the sub-issues API only walks
|
|
1197
|
+
# downward from the parent.
|
|
1198
|
+
parent=$(printf '%s' "$(gh issue view "$closed_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
|
|
1199
|
+
| grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
|
|
1200
|
+
if [ -z "$parent" ]; then
|
|
1201
|
+
echo "#$closed_issue is not a stack child issue (no parent found); nothing stack-related to do."
|
|
1202
|
+
else
|
|
1203
|
+
root_pr=$(printf '%s' "$(gh issue view "$parent" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
|
|
1204
|
+
| grep -oE '<!-- stack-root-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
|
|
1205
|
+
# Case-insensitive on purpose, here and in the child count below: `gh pr view` and
|
|
1206
|
+
# `gh issue view` read GraphQL and answer OPEN, where the REST API answers open. An
|
|
1207
|
+
# exact `open` never matched, so no root was ever open and no child was ever counted
|
|
1208
|
+
# open, and the stack-complete announcement could not fire (Lyceum #44, router run
|
|
1209
|
+
# 38752856518: "Stack #44 has no open root pull request" with #70 open).
|
|
1210
|
+
root_open=false
|
|
1211
|
+
[ -n "$root_pr" ] && root_open=$(gh pr view "$root_pr" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && echo true || echo false)
|
|
1212
|
+
if [ "$root_open" != true ]; then
|
|
1213
|
+
echo "Stack #$parent has no open root pull request; nothing to announce."
|
|
1214
|
+
else
|
|
1215
|
+
# The same child listing refine uses to hand work out: the sub-issues
|
|
1216
|
+
# link safe_outputs writes, with the body-marker search as fallback. A
|
|
1217
|
+
# stack parent always has children, so a listing that finds none means
|
|
1218
|
+
# the lookups failed, and announcing completion of a stack we cannot
|
|
1219
|
+
# see would be wrong -- stay silent and let a rerun decide.
|
|
1220
|
+
children=$(gh api "repos/$REPO/issues/$parent/sub_issues" --jq '.[].number' 2>/dev/null || true)
|
|
1221
|
+
if [ -z "$children" ]; then
|
|
1222
|
+
children=$(gh issue list --repo "$REPO" --state open --limit 50 \
|
|
1223
|
+
--search "\"<!-- split-parent: ${parent} -->\" in:body" --json number --jq '.[].number' || true)
|
|
1224
|
+
fi
|
|
1225
|
+
if [ -z "$children" ]; then
|
|
1226
|
+
echo "Cannot list any child of stack #$parent; leaving completion undetected for a rerun."
|
|
1227
|
+
else
|
|
1228
|
+
# Release the children this landing unblocks. check-stack-branch holds a child
|
|
1229
|
+
# whose `Blocked by #N` sibling is still open, and nothing else re-dispatches it,
|
|
1230
|
+
# so the landing does: re-adding `implement` with the App token is a bot label
|
|
1231
|
+
# event, which the router routes straight to implement. The child that just
|
|
1232
|
+
# landed counts as done even before the gate closes its issue a moment later.
|
|
1233
|
+
for child in $children; do
|
|
1234
|
+
[ "$child" = "$closed_issue" ] && continue
|
|
1235
|
+
meta=$(gh issue view "$child" --repo "$REPO" --json state,labels,body 2>/dev/null || echo '{}')
|
|
1236
|
+
printf '%s' "$meta" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
|
|
1237
|
+
printf '%s' "$meta" | jq -e '[.labels[]?.name] | (index("implement") != null) and (index("bot-working") == null)' >/dev/null || continue
|
|
1238
|
+
blockers=$(printf '%s' "$meta" | jq -r '.body // ""' | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
|
1239
|
+
[ -n "$blockers" ] || continue
|
|
1240
|
+
waiting=""
|
|
1241
|
+
for b in $blockers; do
|
|
1242
|
+
[ "$b" = "$closed_issue" ] && continue
|
|
1243
|
+
if gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
|
|
1244
|
+
waiting="$waiting #$b"
|
|
1245
|
+
fi
|
|
1246
|
+
done
|
|
1247
|
+
if [ -n "$waiting" ]; then
|
|
1248
|
+
echo "#$child still waits on$waiting."
|
|
1249
|
+
continue
|
|
1250
|
+
fi
|
|
1251
|
+
gh issue edit "$child" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
|
|
1252
|
+
gh issue edit "$child" --repo "$REPO" --add-label implement >/dev/null
|
|
1253
|
+
echo "Released #$child: everything it was blocked by has landed."
|
|
1254
|
+
done
|
|
1255
|
+
open_children=0
|
|
1256
|
+
for child in $children; do
|
|
1257
|
+
# Landed, though the gate may not have closed its issue yet.
|
|
1258
|
+
[ "$child" = "$closed_issue" ] && continue
|
|
1259
|
+
if gh issue view "$child" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
|
|
1260
|
+
open_children=$((open_children + 1))
|
|
1261
|
+
fi
|
|
1262
|
+
done
|
|
1263
|
+
if [ "$open_children" -gt 0 ]; then
|
|
1264
|
+
echo "$open_children child issue(s) still open on stack #$parent; the stack continues."
|
|
1265
|
+
else
|
|
1266
|
+
# The root pull request must be green and mergeable. Green: a successful
|
|
1267
|
+
# CI run on its head SHA. Mergeable: the same on-demand computation
|
|
1268
|
+
# every gate dispatch already relies on; UNKNOWN settles in the
|
|
1269
|
+
# background, so ask until it does, like mergeable_state() does.
|
|
1270
|
+
root_state=UNKNOWN
|
|
1271
|
+
for _ in 1 2 3 4 5 6; do
|
|
1272
|
+
root_state=$(gh pr view "$root_pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
|
|
1273
|
+
[ "$root_state" = "UNKNOWN" ] || break
|
|
1274
|
+
sleep 5
|
|
1275
|
+
done
|
|
1276
|
+
root_sha=$(gh api "repos/$REPO/pulls/$root_pr" --jq '.head.sha')
|
|
1277
|
+
root_ci=$(gh api "repos/$REPO/actions/runs?head_sha=$root_sha&per_page=100" \
|
|
1278
|
+
--jq '[.workflow_runs[]
|
|
1279
|
+
| select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
|
|
1280
|
+
and .conclusion == "success")]
|
|
1281
|
+
| sort_by(.created_at) | last // empty')
|
|
1282
|
+
if [ -z "$root_ci" ] || [ "$root_state" != "MERGEABLE" ]; then
|
|
1283
|
+
echo "Root PR #$root_pr is not green/mergeable yet; the human will merge when it is."
|
|
1284
|
+
else
|
|
1285
|
+
completion_marker='<!-- stack-complete -->'
|
|
1286
|
+
# The announcement is a pull request comment, and pull request
|
|
1287
|
+
# comments live at the issue endpoint for the pull request number,
|
|
1288
|
+
# so the marker is looked for where the comment was posted.
|
|
1289
|
+
already=$(gh api "repos/$REPO/issues/$root_pr/comments?per_page=100" \
|
|
1290
|
+
--jq "[.[] | select(.body | contains(\"$completion_marker\"))] | length" 2>/dev/null || echo 0)
|
|
1291
|
+
if [ "${already:-0}" -gt 0 ]; then
|
|
1292
|
+
echo "Stack #$parent already has a completion announcement; staying silent."
|
|
1293
|
+
else
|
|
1294
|
+
gh pr comment "$root_pr" --repo "$REPO" --body "Stack complete: all children have merged into the feature branch and CI is green. This root pull request is ready for a human to review and merge into $DEFAULT_BRANCH. $completion_marker"
|
|
1295
|
+
echo "Stack #$parent complete: announced root PR #$root_pr."
|
|
1296
|
+
fi
|
|
1297
|
+
fi
|
|
1298
|
+
fi
|
|
1299
|
+
fi
|
|
1300
|
+
fi
|
|
1301
|
+
fi
|
|
1302
|
+
fi
|
|
1303
|
+
fi
|
|
976
1304
|
else
|
|
977
1305
|
maybe_dispatch "$EVENT_PR"
|
|
978
1306
|
fi
|