Loop Caller Workflows Design¶
Shared GitHub Actions layout for on-loop-*.yaml caller workflows.
Scope: job graph, target_matrix handoff, triggers, concurrency, matrix fan-out, persistence job structure.
Out of scope: domain detect logic — see workflow designs. Platform target model — Multi-Branch Loops Design.
Refactor complete: Job graph lives in
ci-loop-caller.yaml; eachon-loop-*.yamlis a thin caller (with:only, noenv:). See Loop Caller Reusable Workflow Design.
Files¶
| Type | Path | Role |
|---|---|---|
| Caller | .github/workflows/on-loop-<name>.yaml |
Triggers, concurrency; loop config via with: |
| Reusable | .github/workflows/ci-loop-caller.yaml |
Shared detect → execute → record-skip |
| Reusable | .github/workflows/ci-loop-agent.yaml |
L1/L2/L3 execute (loop-execute) |
| Actions | y-miyazaki/config loop-detect, loop-finalize, … |
Generic phases |
Job Graph¶
Canonical diagram: Loop Engineering Design — Workflow Architecture Diagram. Job names and optional ack-trigger: Loop Caller Reusable Workflow Design.
Handoff-only (not in that diagram):
loop-detectuploads loop-handoff; slimtarget_matrixcells carrytarget_json,prompt,handoff_key.- Execute downloads the artifact and resolves
result/verifier_contextbyhandoff_key. record-skiprunsloop-run-logwhenshould_run=falseandskip_reasonisbudgetorcircuit_breaker. Not used fortarget_budget(execute still runs; deferral is informational).
Detect Job¶
Required outputs¶
| Output | Source |
|---|---|
should_run |
loop-detect — true when target_matrix is non-empty |
skip_reason |
loop-detect |
handoff_artifact_name |
loop-detect — artifact name for execute download (empty when should_run=false) |
target_matrix |
Slim JSON array of candidates (see Specification) |
| Config passthrough | level, models, allowlist, state_file, … |
Each slim matrix cell carries: target_json, prompt, handoff_key. Full result and verifier_context live in the loop-handoff artifact (payloads/<sanitized-key>.json).
Anti-pattern: double detect¶
Forbidden: invoking the detect script again in a caller run: step after loop-detect. Resolved in dogfood — detect facts and verifier_context are emitted once per loop-detect invocation.
Target selection (inside loop-detect)¶
- Read caller inputs (
branch_match,pr_enabled,level, …). - Enumerate integration branches / PRs; checkout per context; run
detect_scriptper context. - Apply trigger-aware priority.
- Cap at
max_targets_per_schedule; excess →target_budgeton next cron.
Execute Job (matrix)¶
Reusable workflow matrix cells cannot pair needs.execute.outputs.* with a separate finalize matrix job — GitHub Actions collapses reusable-workflow outputs across cells. Finalize runs inside ci-loop-agent.yaml (finalize-l1 after agent-l1, finalize-l2 after agent-l2). finalize_enabled only gates the loop-finalize git-landing step on finalize-l2.
execute:
needs: detect
if: needs.detect.outputs.should_run == 'true'
strategy:
matrix:
target: ${{ fromJson(needs.detect.outputs.target_matrix) }}
uses: ./.github/workflows/ci-loop-agent.yaml
with:
base_branch: ${{ matrix.target.target_json.mode == 'pull_request' && matrix.target.target_json.base.branch || matrix.target.target_json.to.branch }}
current_sha: ${{ matrix.target.target_json.from.ref }}
detect_result_json: "{}"
finalize_enabled: true
handoff_artifact_name: ${{ needs.detect.outputs.handoff_artifact_name }}
handoff_key: ${{ matrix.target.handoff_key }}
prompt_text: ${{ matrix.target.prompt }}
target_json: ${{ toJson(matrix.target.target_json) }}
# … passthrough from detect + finalize config
target_json is required. verifier_context and result are resolved at runtime from the loop-handoff artifact (or inline detect_result_json when non-empty).
DEFAULT_LEVEL vs finalize:
auto_merge: ${{ needs.detect.outputs.delivery == 'open_pr' && needs.detect.outputs.level == 'L3' && matrix.target.target_json.finalize == 'open_pr' }}
Finalize (inside ci-loop-agent)¶
finalize-l2 always runs after agent-l2 (unless skipped/cancelled) so run-log is written even when delivery is none. When finalize_enabled=true, the same job runs loop-finalize in the same workflow instance, preserving execute output pairing per matrix cell. finalize-l1 always runs after agent-l1 and currently persists run-log only.
When target_json.to.pr_number is set, ci-loop-agent runs loop-notify-pr as a sibling step immediately after loop-finalize (not nested inside the composite — see composite action composition). See loop-notify-pr Specification.
Persistence layer¶
All .loop/* writes in finalize step via loop-finalize — not separate caller git push steps.
| Input | Example |
|---|---|
target_json |
Matrix cell |
domain_persistence_script |
ci-sweeper: update_run_ledger.sh; docs-updater: empty |
state_push_branch |
branch_state input or default branch |
Push branch: branch_state, not target.to.branch.
Merge-gated state (L2 open_pr): loop-finalize writes pending to branch_state after creating the domain-only PR. on-loop-state-promote.yaml (pull_request_target closed) promotes pending → last_sha on merge (direct push preferred; auto-merge state PR when push is blocked). L3 push / push_head advances last_sha in the same finalize run.
Invariant: Finalize does not edit application/doc source under repair.
Triggers¶
Document applicable triggers in every caller. Prefer one primary poll/event path plus workflow_dispatch.
Dogfood on-loop-ci-sweeper.yaml uses workflow_run (repair-target workflows: list) + workflow_dispatch — no schedule. Changelog, docs-updater, and tech-debt callers keep schedule + workflow_dispatch.
# Example: event-driven CI sweeper (dogfood)
on:
workflow_dispatch: {}
workflow_run: # zizmor: ignore[dangerous-triggers] after ops checklist
types: [completed]
workflows:
- on-ci-push-markdown
# ... repair targets only (not on-loop-* / ci-loop-*)
# Example: schedule polling (docs-updater — dogfood cron)
on:
schedule:
- cron: "0 9 * * 1" # Monday 09:00 UTC
workflow_dispatch: {}
# Example: weekly tech-debt scan (on-loop-tech-debt.yaml)
on:
schedule:
- cron: "0 8 * * 1" # Monday 08:00 UTC
workflow_dispatch: {}
| Trigger | Typical use |
|---|---|
schedule |
Integration branch polling (changelog, docs-updater, tech-debt) |
workflow_run |
Low-latency CI failure (ci-sweeper; ops checklist) |
workflow_dispatch |
Manual debug / gh run list scan without an event run ID |
Concurrency¶
concurrency.group cannot use env. Embed the group name in caller workflow YAML.
Shared state branch (callers)¶
Scheduled and workflow_run on-loop-*.yaml callers and on-loop-state-promote.yaml use the same group for a given branch_state (currently loop-state-main):
concurrency:
cancel-in-progress: false
group: loop-state-main
queue: max
Queued runs wait for the active run to finish (detect → execute → finalize) before starting detect, so handoff JSON is never stale relative to peer loop activity. queue: max allows up to 100 pending runs in FIFO order (default queue: single would cancel an existing pending run when a third enters the group).
ci-loop-caller does not set job-level concurrency on execute; matrix cells within one run may still fan out in parallel.
Per-entity group (entity-event callers)¶
on-loop-github-issue-autofix, on-loop-github-issue-triage, and on-loop-github-pr-revise key the group by the Issue / PR they act on (loop-<loop_name>-<number>) rather than by branch_state. See Multi-Branch Loops — Cross-Loop Coordination for the rationale.
Matrix Fan-Out¶
detect:
outputs:
target_matrix: ${{ steps.detect.outputs.target_matrix }}
execute:
needs: detect
strategy:
matrix:
target: ${{ fromJson(needs.detect.outputs.target_matrix) }}
uses: ./.github/workflows/ci-loop-agent.yaml
with:
finalize_enabled: true
Each matrix cell = one max_runs_per_day consumption. Cap enumeration in loop-detect.
Permissions (least privilege pattern)¶
| Job | Typical permissions |
|---|---|
| detect | contents: read, actions: write (upload artifact); profile may add actions: read, pull-requests: read |
| execute | actions: read (download artifact), contents: write, pull-requests: write (when finalize_enabled=true), engine-specific |
| record-skip | contents: write, pull-requests: write (run-log PR fallback on protected branches) |
| finalize | Runs inside ci-loop-agent; inherits caller execute job permissions |
on-loop-state-promote needs contents: write and a token with pull-requests: write when loop-state-promote opens a state PR fallback (dogfood: maintenance bot app token).
ci-loop-agent agent-l2 needs pull-requests: write when loop-run-log or loop-finalize state-write PR fallback opens on protected branches.
Caller input conventions¶
- Keys alphabetically ordered (repository workflow convention).
- Shared caller keys: Loop Caller Inputs Reference.
- Branch/finalize caps: Multi-Branch canonical table (maps
LOOP_*env names toci-loop-callerinputs).
Adding a New Loop Caller¶
Copy a thin on-loop-*.yaml (triggers + with: only). See Loop Caller Reusable Workflow Design.
- Copy
on-loop-changelog.yaml,on-loop-docs-updater.yaml, oron-loop-ci-sweeper.yamlskeleton. - Add
docs/explanation/loop-engineering/workflows/loop-<name>-workflow-design.md. - Link from Multi-Branch workflow index.
- Register in
mkdocs.ymlunder Explanation → Loop Engineering → Loop Workflows. - Package:
.apm/packages/<domain>/<name>/withSKILL.md+scripts/detect_*.sh(+ optional ledger script).
Phase 0 Debt (resolved)¶
Historical debt from early caller implementations. All items below are resolved in current on-loop-*.yaml; retained for audit trail only.
| Debt | Was | Resolution (current) |
|---|---|---|
| Double detect script | on-loop-ci-sweeper re-ran detect in run: |
loop-detect outputs verifier_context per matrix cell |
Caller ledger git push |
ci-sweeper pushed ledger from caller | domain_persistence_script in loop-finalize via ci-loop-agent |
auto_merge: level == L3 without finalize check |
all L2+ callers | delivery == 'open_pr' && level == L3 && finalize == 'open_pr' |
Single DEFAULT_BASE_BRANCH only |
all | LOOP_INTEGRATION_BRANCHES |
docs-updater detect path |
on-loop-docs-updater |
docs-updater/scripts/detect_changes.sh |
Structural baseline: Loop Caller Reusable Workflow Design (ci-loop-caller.yaml).