forgedock 1.7.0 → 1.8.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/README.md +2 -1
- package/bin/engine/admission.mjs +242 -7
- package/bin/engine/admission.test.mjs +183 -2
- package/bin/engine/phases.mjs +31 -22
- package/bin/engine/resolve.mjs +8 -1
- package/bin/engine/resolve.test.mjs +5 -0
- package/bin/engine-cli.mjs +2 -0
- package/bin/engine.mjs +26 -17
- package/bin/forgedock.mjs +124 -48
- package/bin/hooks/pre-tool-use.mjs +573 -2
- package/bin/journey.mjs +11 -7
- package/bin/labels.json +5 -0
- package/bin/opencode-adapter.mjs +652 -92
- package/bin/orchestrate-preflight.mjs +434 -0
- package/bin/runner.mjs +77 -9
- package/commands/audit-agents.md +75 -26
- package/commands/autopilot.md +4 -4
- package/commands/cleanup.md +10 -5
- package/commands/issue.md +95 -35
- package/commands/orchestrate/phase-1-resolve.md +70 -32
- package/commands/orchestrate/phase-3-dependency.md +167 -22
- package/commands/orchestrate/phase-4-execution.md +691 -62
- package/commands/orchestrate/phase-5-cleanup.md +5 -11
- package/commands/orchestrate/phase-6-report.md +27 -1
- package/commands/orchestrate/safety.md +1 -1
- package/commands/orchestrate.md +39 -1
- package/commands/quality-gate.md +30 -10
- package/commands/review-pr-agents/protocols.md +13 -0
- package/commands/review-pr-agents.md +8 -0
- package/commands/review-pr-staging.md +117 -17
- package/commands/review-pr.md +95 -23
- package/commands/work-on/build/validate.md +25 -4
- package/commands/work-on/close.md +40 -10
- package/commands/work-on/decompose.md +33 -1
- package/commands/work-on/investigate.md +33 -0
- package/commands/work-on/remediate.md +40 -5
- package/commands/work-on/review.md +27 -0
- package/commands/work-on.md +48 -5
- package/docs/CONFIG.md +51 -15
- package/docs/OPENCODE.md +128 -25
- package/docs/spec/review-protocol.md +7 -0
- package/package.json +1 -1
- package/scripts/check-admission-parity.mjs +1 -0
- package/scripts/check-command-side-effects.sh +14 -13
- package/scripts/check-shell-portability.sh +36 -0
- package/scripts/check-spec-markers.sh +21 -11
- package/scripts/checkbox-sections.test.sh +67 -0
- package/scripts/extract-affected-files.sh +85 -5
- package/scripts/extract-affected-files.test.sh +154 -2
|
@@ -189,6 +189,8 @@ should_dispatch() {
|
|
|
189
189
|
|
|
190
190
|
**WHY THIS EXISTS** <!-- Added: forge#1912 -->: Phase 4's dispatch loops previously launched every DAG-ready issue in one shot — the engine-first bash loop backgrounded every `should_dispatch()`-passing issue with no count limit, and the Agent-spawn-fallback path was explicitly instructed to "launch all ready agents simultaneously." On a large ready set this saturates the Anthropic API rate limit in one burst, causing cascading failures across most of the batch. This is a distinct, count-denominated gate from the dollar-denominated `--budget` gate above (Step 4A-pre.0) — an issue can be deferred for budget reasons, concurrency reasons, or both; each gate accumulates its own deferred list independently.
|
|
191
191
|
|
|
192
|
+
`MAX_CONCURRENT` caps top-level `/work-on` dispatches, not the total number of harness subagents. A normal worker can spawn context, architect, implement, validate, quality-gate, and review children. Budget **8 total subagent spawns per worker** when choosing this value: `max_concurrent <= session_subagent_budget / 8`. This is a conservative planning multiplier, not a second runtime semaphore; the harness does not expose a reliable cross-agent spawn counter to this shell-level dispatcher.
|
|
193
|
+
|
|
192
194
|
Initialize the concurrency cap and its batch-scope tracking state before the first dispatch:
|
|
193
195
|
|
|
194
196
|
```bash
|
|
@@ -209,8 +211,12 @@ fi
|
|
|
209
211
|
|
|
210
212
|
ACTIVE_DISPATCH_COUNT=0 # in-flight dispatched-but-not-yet-completed agents, this batch
|
|
211
213
|
DEFERRED_CONCURRENCY_ISSUES=() # ready issues held back because no headroom was available
|
|
214
|
+
SUBAGENT_SPAWN_BUDGET_PER_WORKER=8 # /work-on + build/review fan-out planning estimate
|
|
215
|
+
TOP_LEVEL_DISPATCH_TOTAL=0
|
|
216
|
+
PLANNED_SUBAGENT_SPAWNS=0
|
|
217
|
+
OBSERVED_SUBAGENT_SPAWNS="unavailable" # replace only with harness telemetry; never infer
|
|
212
218
|
|
|
213
|
-
echo "Concurrency gate initialized: MAX_CONCURRENT=${MAX_CONCURRENT} (
|
|
219
|
+
echo "Concurrency gate initialized: MAX_CONCURRENT=${MAX_CONCURRENT}; plan for up to $((MAX_CONCURRENT * SUBAGENT_SPAWN_BUDGET_PER_WORKER)) subagent spawns in flight (8 per worker)"
|
|
214
220
|
# --- End concurrency gate initialization ---
|
|
215
221
|
```
|
|
216
222
|
|
|
@@ -226,6 +232,31 @@ dispatch_headroom() {
|
|
|
226
232
|
|
|
227
233
|
**This is a hard cap, not a suggestion.** No dispatch site in Phase 4 may background/spawn more than `dispatch_headroom` new agents without first waiting for in-flight agents to complete. Issues that cannot be dispatched due to the cap go into `DEFERRED_CONCURRENCY_ISSUES[]` and are released — in the order they were deferred — as running agents complete (Step 4B), the same event-driven model (no sleep/poll loops) the file already uses for DAG-readiness and budget gating.
|
|
228
234
|
|
|
235
|
+
**Dispatch accounting**: Immediately after each successful top-level dispatch, increment `TOP_LEVEL_DISPATCH_TOTAL` and add `SUBAGENT_SPAWN_BUDGET_PER_WORKER` to `PLANNED_SUBAGENT_SPAWNS`. When the runtime reports child-spawn telemetry, replace `OBSERVED_SUBAGENT_SPAWNS` with that measured total; otherwise leave it `unavailable` rather than fabricating a measured value.
|
|
236
|
+
|
|
237
|
+
### Step 4A-pre.0.3: Secondary content-creation backpressure (MANDATORY)
|
|
238
|
+
|
|
239
|
+
GitHub's `resources.core` quota does not report secondary content-creation throttles. Treat any GitHub API response with HTTP `403` whose body contains `secondary rate limit` as a batch-wide backpressure signal.
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
# Call from every dispatcher-owned gh write/create wrapper and from a child completion
|
|
243
|
+
# report that includes its final GitHub API error. Do not retry the failed request here.
|
|
244
|
+
SECONDARY_RATE_LIMITED=false
|
|
245
|
+
SECONDARY_RATE_LIMIT_MESSAGE=""
|
|
246
|
+
|
|
247
|
+
record_secondary_rate_limit() {
|
|
248
|
+
local STATUS="$1"
|
|
249
|
+
local BODY="$2"
|
|
250
|
+
if [ "$STATUS" = "403" ] && echo "$BODY" | grep -qi 'secondary rate limit'; then
|
|
251
|
+
SECONDARY_RATE_LIMITED=true
|
|
252
|
+
SECONDARY_RATE_LIMIT_MESSAGE="$BODY"
|
|
253
|
+
echo "SECONDARY RATE LIMIT: pausing all new dispatch. Do not retry GitHub content creation until an operator resumes the batch." >&2
|
|
254
|
+
fi
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Before every dispatch site computes headroom or launches a worker, check `SECONDARY_RATE_LIMITED`. When true, preserve the ready/deferred queues, launch no more workers, and end the active dispatch cycle. Do not poll or retry GitHub calls to discover recovery: an operator resumes the batch after the throttle clears. In-flight agents receive this contract in the dispatch prompt: on a secondary-limit 403, they must stop their GitHub write/create retry loop, report the response to the orchestrator, and exit their current phase without creating replacement work.
|
|
259
|
+
|
|
229
260
|
### Step 4A-pre: Staging baseline tracking (MANDATORY — continuous)
|
|
230
261
|
|
|
231
262
|
**WHY THIS EXISTS**: Milestone-code-onto-staging contamination incidents (see issue #150) produce unexpected growth on the staging branch that is otherwise invisible until after a deploy. In the streaming DAG model, there are no discrete wave boundaries — instead, track a running baseline and check after each agent completion.
|
|
@@ -409,6 +440,16 @@ echo "Step 4A.pre: using BATCH_T0=${BATCH_T0} for Step 4C's run-spawned cascade
|
|
|
409
440
|
DEFERRED_FINDINGS=()
|
|
410
441
|
QUEUED_FINDINGS=()
|
|
411
442
|
declare -A DEFERRED_REASONS
|
|
443
|
+
# Count every finding regardless of admission, so a deferred refinement never
|
|
444
|
+
# makes the reported amplification ratio look better than it was.
|
|
445
|
+
FINDINGS_SPAWNED=0
|
|
446
|
+
MERGED_UNITS=0
|
|
447
|
+
AMPLIFICATION_RATIO_HISTORY=()
|
|
448
|
+
AMPLIFICATION_DEFERRED=()
|
|
449
|
+
declare -A FINDINGS_BY_SOURCE_PR
|
|
450
|
+
declare -A REFINEMENT_FINDINGS
|
|
451
|
+
declare -A NEW_SURFACE_FINDINGS
|
|
452
|
+
declare -A AMPLIFICATION_FINDING_SEEN
|
|
412
453
|
declare -A AGENT_ISSUE_MAP
|
|
413
454
|
# Engine-first dispatch equivalent of AGENT_ISSUE_MAP (fixed forge#2466): keyed by issue
|
|
414
455
|
# number, holds the task id returned by each backgrounded `Bash(run_in_background=true,
|
|
@@ -416,6 +457,29 @@ declare -A AGENT_ISSUE_MAP
|
|
|
416
457
|
# this map to identify which issue a background-Bash completion notification belongs to,
|
|
417
458
|
# the same role AGENT_ISSUE_MAP plays for Agent-tool `agent_completed` notifications.
|
|
418
459
|
declare -A ENGINE_DISPATCH_MAP
|
|
460
|
+
# OpenCode native task equivalent of ENGINE_DISPATCH_MAP. Keys are issue numbers;
|
|
461
|
+
# values are the session ids returned by task(background=true). This is a live
|
|
462
|
+
# cache only; every entry must also be persisted as a FORGE:DISPATCH comment.
|
|
463
|
+
declare -A OPENCODE_DISPATCH_MAP
|
|
464
|
+
|
|
465
|
+
# Reconstruct the live map after compaction/restart from the durable dispatch
|
|
466
|
+
# records. The issue association is the map key; only the latest running record
|
|
467
|
+
# is restored, because a later completed/error record has already released it.
|
|
468
|
+
for NUM in "${ISSUES[@]}"; do
|
|
469
|
+
DISPATCH_BODY=$(gh api repos/{GH_REPO}/issues/"$NUM"/comments \
|
|
470
|
+
--jq '[.[] | select(.body | contains("FORGE:DISPATCH")) | .body] | last // ""' 2>/dev/null || true)
|
|
471
|
+
TASK_ID=$(printf '%s' "$DISPATCH_BODY" | jq -Rr 'try capture("\\\"runtime\\\":\\\"opencode\\\",\\\"child_session_id\\\":\\\"(?<id>[^\\\"]+)\\\".*\\\"state\\\":\\\"running\\\"").id catch ""')
|
|
472
|
+
if [ -n "$TASK_ID" ]; then
|
|
473
|
+
OPENCODE_DISPATCH_MAP["$NUM"]="$TASK_ID"
|
|
474
|
+
fi
|
|
475
|
+
done
|
|
476
|
+
|
|
477
|
+
# Full-repository intake is intentionally separate from Step 4C's T0-scoped
|
|
478
|
+
# review-finding cascade. It is enabled only for an operator-authorised
|
|
479
|
+
# policy: all run; records every open issue already observed so CI-created
|
|
480
|
+
# actionable work is admitted once rather than rediscovered every cycle.
|
|
481
|
+
declare -A SEEN_OPEN_ISSUES
|
|
482
|
+
FULL_REPO_SWEEP_COUNT=0
|
|
419
483
|
|
|
420
484
|
# Same-file current-state brief forwarding (forge#1860). Populated by the core streaming
|
|
421
485
|
# dispatch loop below (Step 4B) whenever a Layer 1/2/3 structural predecessor edge (see
|
|
@@ -425,6 +489,42 @@ declare -A ENGINE_DISPATCH_MAP
|
|
|
425
489
|
declare -A SAME_FILE_BRIEF
|
|
426
490
|
declare -A EDGE_BRIEFED
|
|
427
491
|
|
|
492
|
+
# DONE-path edge re-verification memo (forge#2848). A conclusive re-derivation is
|
|
493
|
+
# memoized in EDGE_REDERIVED; inconclusive results get one retry, capped by
|
|
494
|
+
# EDGE_REDERIVE_ATTEMPTS. This avoids caching a transient failure while bounding
|
|
495
|
+
# extraction to two attempts per descendant per batch.
|
|
496
|
+
declare -A EDGE_REDERIVED
|
|
497
|
+
declare -A EDGE_REDERIVE_ATTEMPTS
|
|
498
|
+
|
|
499
|
+
# Affected-file extraction helper, resolved for the DONE-path cohort re-derivation
|
|
500
|
+
# (forge#2848). Same resolver precedence as phase-3-dependency.md Step 3C Layer 1 —
|
|
501
|
+
# ForgeDock's runtime installation before the target repository — because the
|
|
502
|
+
# orchestrator runs inside the project being worked on, where a bare
|
|
503
|
+
# `bash scripts/extract-affected-files.sh` silently fails when that project has not
|
|
504
|
+
# copied ForgeDock's helper scripts into its own repository (#2794/#2791).
|
|
505
|
+
resolve_extract_affected_files() {
|
|
506
|
+
local candidates=()
|
|
507
|
+
[ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/extract-affected-files.sh")
|
|
508
|
+
[ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/extract-affected-files.sh")
|
|
509
|
+
candidates+=("$PWD/scripts/extract-affected-files.sh")
|
|
510
|
+
|
|
511
|
+
local candidate
|
|
512
|
+
for candidate in "${candidates[@]}"; do
|
|
513
|
+
if [ -f "$candidate" ]; then
|
|
514
|
+
printf '%s\n' "$candidate"
|
|
515
|
+
return 0
|
|
516
|
+
fi
|
|
517
|
+
done
|
|
518
|
+
echo "ERROR: extract-affected-files.sh is not installed in any configured runtime path." >&2
|
|
519
|
+
return 1
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
# Non-fatal here, unlike Phase 3's hard exit: Phase 3 cannot build a DAG at all without
|
|
523
|
+
# this helper, whereas Phase 4 only needs it for the OPTIONAL re-derivation optimization.
|
|
524
|
+
# If it is missing, re-derivation is skipped and edges stay as planned — the conservative
|
|
525
|
+
# direction, and identical to this file's pre-forge#2848 behavior.
|
|
526
|
+
AFFECTED_FILES_SCRIPT=$(resolve_extract_affected_files) || AFFECTED_FILES_SCRIPT=""
|
|
527
|
+
|
|
428
528
|
# Human-gated idle/backpressure flag (Step 4B item 6.7, forge#1814). Starts false —
|
|
429
529
|
# recomputed every completion cycle over {all_batch_issue_numbers}. Declared at batch
|
|
430
530
|
# scope (not per-agent) so Step 4C can read the latest value on every iteration.
|
|
@@ -449,15 +549,15 @@ CASCADE_POLICY_NAME=$(yq '.orchestration.cascade.policy // "balanced"' forge.yam
|
|
|
449
549
|
|
|
450
550
|
case "$CASCADE_POLICY_NAME" in
|
|
451
551
|
all)
|
|
452
|
-
PRESET_MAX_GEN="unlimited"; PRESET_TOKEN_BUDGET="unlimited"; PRESET_DEFER_GATED="false"; PRESET_KEYWORD="false"; PRESET_P3_SAME_FILE="false" ;;
|
|
552
|
+
PRESET_MAX_GEN="unlimited"; PRESET_BATCH_MAX_GEN=2; PRESET_TOKEN_BUDGET="unlimited"; PRESET_DEFER_GATED="false"; PRESET_KEYWORD="false"; PRESET_P3_SAME_FILE="false" ;;
|
|
453
553
|
conservative)
|
|
454
|
-
PRESET_MAX_GEN=1; PRESET_TOKEN_BUDGET=450000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
|
|
554
|
+
PRESET_MAX_GEN=1; PRESET_BATCH_MAX_GEN=2; PRESET_TOKEN_BUDGET=450000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
|
|
455
555
|
balanced)
|
|
456
|
-
PRESET_MAX_GEN=1; PRESET_TOKEN_BUDGET=900000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
|
|
556
|
+
PRESET_MAX_GEN=1; PRESET_BATCH_MAX_GEN=2; PRESET_TOKEN_BUDGET=900000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
|
|
457
557
|
*)
|
|
458
558
|
echo "WARNING: forge.yaml → orchestration.cascade.policy \"${CASCADE_POLICY_NAME}\" is not one of: all, balanced, conservative — falling back to \"balanced\""
|
|
459
559
|
CASCADE_POLICY_NAME="balanced"
|
|
460
|
-
PRESET_MAX_GEN=1; PRESET_TOKEN_BUDGET=900000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
|
|
560
|
+
PRESET_MAX_GEN=1; PRESET_BATCH_MAX_GEN=2; PRESET_TOKEN_BUDGET=900000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
|
|
461
561
|
esac
|
|
462
562
|
|
|
463
563
|
# max_generation is authoritatively resolved in phase-1-resolve.md (it only governs
|
|
@@ -470,6 +570,14 @@ if [ "$MAX_GENERATION_FOR_NOTICE" != "unlimited" ] && ! echo "$MAX_GENERATION_FO
|
|
|
470
570
|
MAX_GENERATION_FOR_NOTICE="$PRESET_MAX_GEN"
|
|
471
571
|
fi
|
|
472
572
|
|
|
573
|
+
# P3 batching is a distinct, bounded aggregation exception to autonomous cascade
|
|
574
|
+
# admission. Unlike max_generation, it must always be finite, including policy: all.
|
|
575
|
+
BATCH_MAX_GENERATION=$(yq ".orchestration.cascade.batch_max_generation // ${PRESET_BATCH_MAX_GEN}" forge.yaml 2>/dev/null || echo "$PRESET_BATCH_MAX_GEN")
|
|
576
|
+
if ! echo "$BATCH_MAX_GENERATION" | grep -qE '^[1-9][0-9]*$'; then
|
|
577
|
+
echo "WARNING: forge.yaml → orchestration.cascade.batch_max_generation is not a positive integer (\"${BATCH_MAX_GENERATION}\") — falling back to default ${PRESET_BATCH_MAX_GEN}"
|
|
578
|
+
BATCH_MAX_GENERATION="$PRESET_BATCH_MAX_GEN"
|
|
579
|
+
fi
|
|
580
|
+
|
|
473
581
|
# token_budget precedence: orchestration.cascade.token_budget (new home) >
|
|
474
582
|
# pipeline.token_budget_per_batch (deprecated alias, forge#1858, kept working
|
|
475
583
|
# unchanged) > the resolved preset default. Accepts the "unlimited" sentinel —
|
|
@@ -488,13 +596,30 @@ fi
|
|
|
488
596
|
|
|
489
597
|
TOKEN_ESTIMATE_PER_FINDING=$(yq '.pipeline.token_estimate_per_finding // 150000' forge.yaml 2>/dev/null || echo 150000)
|
|
490
598
|
|
|
599
|
+
# Amplification is observational by default. The optional ceiling is evaluated
|
|
600
|
+
# only against same-lineage refinements in Step 4C, never against new surface.
|
|
601
|
+
CASCADE_MAX_AMPLIFICATION=$(yq '.orchestration.cascade.max_amplification // "off"' forge.yaml 2>/dev/null || echo "off")
|
|
602
|
+
if [ "$CASCADE_MAX_AMPLIFICATION" != "off" ]; then
|
|
603
|
+
if ! echo "$CASCADE_MAX_AMPLIFICATION" | grep -qE '^[0-9]+(\.[0-9]+)?$' || \
|
|
604
|
+
! awk "BEGIN { exit !($CASCADE_MAX_AMPLIFICATION > 0) }"; then
|
|
605
|
+
echo "WARNING: forge.yaml → orchestration.cascade.max_amplification must be a positive number or off — disabling the bound"
|
|
606
|
+
CASCADE_MAX_AMPLIFICATION="off"
|
|
607
|
+
fi
|
|
608
|
+
fi
|
|
609
|
+
CONVERGENCE_WINDOW=$(yq '.orchestration.cascade.convergence_window // 3' forge.yaml 2>/dev/null || echo 3)
|
|
610
|
+
if ! echo "$CONVERGENCE_WINDOW" | grep -qE '^[1-9][0-9]*$'; then
|
|
611
|
+
echo "WARNING: forge.yaml → orchestration.cascade.convergence_window must be a positive integer — falling back to 3"
|
|
612
|
+
CONVERGENCE_WINDOW=3
|
|
613
|
+
fi
|
|
614
|
+
echo "Cascade amplification: max_amplification=${CASCADE_MAX_AMPLIFICATION} convergence_window=${CONVERGENCE_WINDOW} (off preserves current admission behavior)"
|
|
615
|
+
|
|
491
616
|
# Independent boolean levers — each accepts an explicit granular override on
|
|
492
617
|
# top of the resolved preset (preset supplies the default, not a hard value).
|
|
493
618
|
CASCADE_DEFER_ON_BATCH_GATED=$(yq ".orchestration.cascade.defer_on_batch_gated // ${PRESET_DEFER_GATED}" forge.yaml 2>/dev/null || echo "$PRESET_DEFER_GATED")
|
|
494
619
|
CASCADE_KEYWORD_HEURISTIC=$(yq ".orchestration.cascade.keyword_heuristic // ${PRESET_KEYWORD}" forge.yaml 2>/dev/null || echo "$PRESET_KEYWORD")
|
|
495
620
|
CASCADE_P3_SAME_FILE_DEFER=$(yq ".orchestration.cascade.p3_same_file_defer // ${PRESET_P3_SAME_FILE}" forge.yaml 2>/dev/null || echo "$PRESET_P3_SAME_FILE")
|
|
496
621
|
|
|
497
|
-
echo "Cascade admission policy resolved: policy=${CASCADE_POLICY_NAME} token_budget=${TOKEN_BUDGET} defer_on_batch_gated=${CASCADE_DEFER_ON_BATCH_GATED} keyword_heuristic=${CASCADE_KEYWORD_HEURISTIC} p3_same_file_defer=${CASCADE_P3_SAME_FILE_DEFER} (forge.yaml → orchestration.cascade; see docs/CONFIG.md)"
|
|
622
|
+
echo "Cascade admission policy resolved: policy=${CASCADE_POLICY_NAME} token_budget=${TOKEN_BUDGET} batch_max_generation=${BATCH_MAX_GENERATION} defer_on_batch_gated=${CASCADE_DEFER_ON_BATCH_GATED} keyword_heuristic=${CASCADE_KEYWORD_HEURISTIC} p3_same_file_defer=${CASCADE_P3_SAME_FILE_DEFER} (forge.yaml → orchestration.cascade; see docs/CONFIG.md)"
|
|
498
623
|
|
|
499
624
|
# Both-uncapped notice (loud, one-time, printed once per orchestrate invocation since
|
|
500
625
|
# this resolution block itself runs once at Step 4A.pre) — never a preset default except
|
|
@@ -521,6 +646,8 @@ TOKEN_DEFERRED=() # findings deferred by the token-budget rule this run (re-ev
|
|
|
521
646
|
# alone. <!-- Added: forge#1909 -->
|
|
522
647
|
SURFACE_BATCHED_FINDINGS=() # all member issue numbers absorbed into a batch across the run
|
|
523
648
|
SURFACE_BATCH_COUNT=0 # count of batch issues created across the run
|
|
649
|
+
BATCHABLE_DEFERRED_P3=() # gen-2+ P3s eligible only for bounded P3 aggregation (forge#2849)
|
|
650
|
+
declare -A FINDING_GENERATIONS
|
|
524
651
|
|
|
525
652
|
for NUM in {ready_issue_numbers}; do
|
|
526
653
|
PR_BASE=$(bash "$CLASSIFY_LANE_SCRIPT" "$NUM" -R {GH_REPO}) || {
|
|
@@ -544,6 +671,97 @@ Use `${ISSUE_LANE[$NUM]}` and `${ISSUE_PR_BASE[$NUM]}` to populate `{LANE}` and
|
|
|
544
671
|
|
|
545
672
|
### Step 4A: Dispatch ready issues
|
|
546
673
|
|
|
674
|
+
### Step 4A.0: Probe Knowledge Gist capability once
|
|
675
|
+
|
|
676
|
+
Probe the authenticated identity once for this orchestration run before any engine, Agent, or
|
|
677
|
+
OpenCode worker is dispatched. A GitHub App installation token identifies as `Bot` and cannot use
|
|
678
|
+
the Gists API; cache that fact rather than letting every worker rediscover it by attempting a
|
|
679
|
+
write. An unavailable identity probe preserves existing behavior for PAT-authenticated runs.
|
|
680
|
+
|
|
681
|
+
```bash
|
|
682
|
+
if [ -z "${FORGE_GIST_CAPABLE+x}" ]; then
|
|
683
|
+
GIST_AUTH_TYPE=$(gh api user --jq '.type' 2>/dev/null || true)
|
|
684
|
+
if [ "$GIST_AUTH_TYPE" = "Bot" ]; then
|
|
685
|
+
FORGE_GIST_CAPABLE=false
|
|
686
|
+
else
|
|
687
|
+
FORGE_GIST_CAPABLE=true
|
|
688
|
+
fi
|
|
689
|
+
export FORGE_GIST_CAPABLE
|
|
690
|
+
fi
|
|
691
|
+
|
|
692
|
+
if [ "$FORGE_GIST_CAPABLE" = "true" ]; then
|
|
693
|
+
echo "Knowledge Gist capability available"
|
|
694
|
+
else
|
|
695
|
+
echo "INFO: Knowledge Gist subsystem unavailable for this authentication; workers will skip it"
|
|
696
|
+
fi
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
Carry `FORGE_GIST_CAPABLE` unchanged through every dispatch path. Do not probe it in individual
|
|
700
|
+
workers dispatched by this run. Phase 6 reports a false value once at batch level.
|
|
701
|
+
|
|
702
|
+
**Claims-board dispatch gate (MANDATORY, before every individual dispatch)** <!-- Added: forge#2844 -->: The coordination issue is the durable authority for file ownership. Do not use `EDGE_FILES`, `ISSUE_FILES`, or a remembered prior read as evidence that a claim is free. Immediately before dispatching each issue, re-read the full claims board and refuse that dispatch when the issue's declared file set intersects a live claim held by another issue. This applies equally to engine, Claude Agent, and OpenCode task dispatches, including newly-ready issues and wake reconstruction.
|
|
703
|
+
|
|
704
|
+
```bash
|
|
705
|
+
# Returns unreleased claims as [{holder: "123", files: "..."}]. A release is a separate,
|
|
706
|
+
# later comment, so it must be paired with its claim by holder rather than searched for in the
|
|
707
|
+
# claim comment itself. Terminal holder states also self-heal claims left behind by dead agents.
|
|
708
|
+
read_active_claims() {
|
|
709
|
+
local COORD_NUM="$1"
|
|
710
|
+
local CLAIMS HOLDER TERMINAL
|
|
711
|
+
CLAIMS=$(gh api --paginate --slurp "repos/{GH_REPO}/issues/${COORD_NUM}/comments" 2>/dev/null \
|
|
712
|
+
| jq -c '
|
|
713
|
+
flatten as $comments |
|
|
714
|
+
[$comments[]
|
|
715
|
+
| select(.body | contains("<!-- FORGE:CLAIM -->"))
|
|
716
|
+
| . as $claim
|
|
717
|
+
| ($claim.body | capture("\\*\\*Holder\\*\\*: #(?<holder>[0-9]+)").holder) as $holder
|
|
718
|
+
| select([$comments[]
|
|
719
|
+
| select(.body | contains("<!-- FORGE:CLAIM_RELEASED -->"))
|
|
720
|
+
| select((.body | capture("\\*\\*Holder\\*\\*: #(?<holder>[0-9]+)").holder) == $holder)
|
|
721
|
+
| select(.created_at > $claim.created_at)] | length == 0)
|
|
722
|
+
| {holder: $holder,
|
|
723
|
+
files: ($claim.body | capture("\\*\\*Files\\*\\*: (?<files>[\\s\\S]*?)(?:\\n\\*\\*Interfaces\\*\\*:|$)").files)}]') || return 1
|
|
724
|
+
|
|
725
|
+
for HOLDER in $(echo "$CLAIMS" | jq -r '.[].holder'); do
|
|
726
|
+
TERMINAL=$(gh issue view "$HOLDER" -R {GH_REPO} --json labels --jq \
|
|
727
|
+
'[.labels[].name | select(. == "workflow:merged" or . == "workflow:invalid" or . == "workflow:awaiting-merge" or . == "needs-human")] | length > 0' 2>/dev/null)
|
|
728
|
+
[ "$TERMINAL" = "true" ] && CLAIMS=$(echo "$CLAIMS" | jq --arg holder "$HOLDER" '[.[] | select(.holder != $holder)]')
|
|
729
|
+
done
|
|
730
|
+
echo "$CLAIMS"
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
claim_conflicts_with_live_holder() {
|
|
734
|
+
local NUM="$1" TARGET_FILES CLAIM HOLDER CLAIM_FILES OVERLAP
|
|
735
|
+
TARGET_FILES=$(printf '%s\n' "${ISSUE_FILES[$NUM]:-}" | sed -E '/^[[:space:]]*$/d; s/^[[:space:]]*[-*][[:space:]]*//; s/`//g' | sort -u)
|
|
736
|
+
[ -z "$TARGET_FILES" ] && return 1 # No declared paths: normal conservative DAG rules still apply.
|
|
737
|
+
|
|
738
|
+
ACTIVE_CLAIMS=$(read_active_claims "$COORD_ISSUE_NUMBER") || {
|
|
739
|
+
echo "REFUSING TO DISPATCH #${NUM}: could not read the durable claims board." >&2
|
|
740
|
+
return 0
|
|
741
|
+
}
|
|
742
|
+
while IFS= read -r CLAIM; do
|
|
743
|
+
HOLDER=$(echo "$CLAIM" | jq -r '.holder')
|
|
744
|
+
[ "$HOLDER" = "$NUM" ] && continue
|
|
745
|
+
CLAIM_FILES=$(echo "$CLAIM" | jq -r '.files' | sed -E '/^[[:space:]]*$/d; s/^[[:space:]]*[-*][[:space:]]*//; s/`//g' | sort -u)
|
|
746
|
+
OVERLAP=$(comm -12 <(printf '%s\n' "$TARGET_FILES") <(printf '%s\n' "$CLAIM_FILES"))
|
|
747
|
+
if [ -n "$OVERLAP" ]; then
|
|
748
|
+
echo "DEFERRING #${NUM}: declared files overlap live claim held by #${HOLDER}: ${OVERLAP//$'\n'/, }"
|
|
749
|
+
return 0
|
|
750
|
+
fi
|
|
751
|
+
done < <(echo "$ACTIVE_CLAIMS" | jq -c '.[]')
|
|
752
|
+
return 1
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
# Run this immediately before each engine Bash, Agent(), or OpenCode task call. Do not batch the
|
|
756
|
+
# board read: another worker can claim a file between two dispatches in the same ready set.
|
|
757
|
+
if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ]; then
|
|
758
|
+
if claim_conflicts_with_live_holder "$NUM"; then
|
|
759
|
+
DEFERRED_CLAIM_ISSUES+=("$NUM")
|
|
760
|
+
continue
|
|
761
|
+
fi
|
|
762
|
+
fi
|
|
763
|
+
```
|
|
764
|
+
|
|
547
765
|
**Engine-first dispatch (default)**: When `forgedock` is in PATH, dispatch each ready issue via the durable engine rather than spawning prose Agent sub-agents. The engine's phase table enforces gate semantics in code — its fail-closed review gate and deterministic phase ordering are not subject to LLM interpretation.
|
|
548
766
|
|
|
549
767
|
**OpenCode dispatch (runtime-neutral adapter)**: When `FORGE_RUNTIME=opencode` or
|
|
@@ -556,10 +774,45 @@ and continue until it reaches
|
|
|
556
774
|
`workflow:merged`, `workflow:invalid`, `needs-human`, or
|
|
557
775
|
`workflow:awaiting-merge`.
|
|
558
776
|
|
|
777
|
+
The native call is asynchronous and must be issued once per issue in
|
|
778
|
+
`DISPATCH_NOW`, in the same assistant message where the batch is dispatched:
|
|
779
|
+
|
|
780
|
+
```
|
|
781
|
+
task(
|
|
782
|
+
description="Work on {PROJECT_PREFIX}#{NUMBER}",
|
|
783
|
+
subagent_type="general",
|
|
784
|
+
background=true,
|
|
785
|
+
prompt="Use the same Phase 4A work-on template below. Before invoking it, run `export FORGE_GIST_CAPABLE={FORGE_GIST_CAPABLE}` so the cached orchestration capability is preserved. Invoke Skill(skill='work-on', args='{PROJECT_PREFIX}{NUMBER} --under-orchestration') and continue until a terminal workflow state."
|
|
786
|
+
)
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
Capture the returned `<task id="..." state="running">` id in
|
|
790
|
+
`OPENCODE_DISPATCH_MAP[{NUMBER}]` and immediately persist it to the issue:
|
|
791
|
+
|
|
792
|
+
```bash
|
|
793
|
+
ATTEMPT=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
|
|
794
|
+
--jq '[.[] | select(.body | contains("FORGE:DISPATCH") and contains("\"state\":\"running\""))] | length + 1')
|
|
795
|
+
if [ "${DRY_RUN:-false}" = "false" ]; then
|
|
796
|
+
gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:DISPATCH -->
|
|
797
|
+
\`\`\`json
|
|
798
|
+
{\"runtime\":\"opencode\",\"child_session_id\":\"{TASK_ID}\",\"attempt\":${ATTEMPT},\"state\":\"running\",\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}
|
|
799
|
+
\`\`\`"
|
|
800
|
+
fi
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
`background=true` is required for streaming DAG behavior; a foreground task
|
|
804
|
+
makes the orchestrator wait for that issue and reintroduces a wave barrier. The ForgeDock OpenCode plugin opts into
|
|
805
|
+
`OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` by default. An explicit
|
|
806
|
+
`OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=false` is a supported degraded mode,
|
|
807
|
+
but it must be reported as non-streaming rather than treated as equivalent to
|
|
808
|
+
Claude background dispatch. Do not wait for the slowest sibling before handling
|
|
809
|
+
an earlier task-result event.
|
|
810
|
+
|
|
559
811
|
```bash
|
|
560
812
|
if [ "${FORGE_RUNTIME:-}" = "opencode" ]; then
|
|
561
813
|
echo "OpenCode native task dispatch selected — load commands/work-on.md for each ready issue."
|
|
562
814
|
echo "Do not call forgedock run-issue or the Claude Agent(...) fallback in this branch."
|
|
815
|
+
echo "Use task(subagent_type=general, background=true) once per ready issue and process each task-result event immediately."
|
|
563
816
|
echo "If native task dispatch cannot run, post FORGE:OPENCODE_BLOCKED with the missing capability and add needs-human."
|
|
564
817
|
fi
|
|
565
818
|
```
|
|
@@ -567,6 +820,13 @@ fi
|
|
|
567
820
|
This branch is additive. When OpenCode is not selected, continue through the
|
|
568
821
|
existing engine-first and Claude Agent-spawn paths below without modification.
|
|
569
822
|
|
|
823
|
+
The generated OpenCode plugin is an executable boundary, not just prompt
|
|
824
|
+
guidance: its `tool.execute.before` hook rejects `claude`, `forgedock
|
|
825
|
+
run-issue`, `npx forgedock run-issue`, and recursive `opencode run` shell
|
|
826
|
+
commands with `FORGE_OPENCODE_CAPABILITY_ERROR`. If a native Skill or Task is
|
|
827
|
+
unavailable, stop with that bounded error and preserve the GitHub label state;
|
|
828
|
+
never compensate by starting a second controller or the Claude-backed engine.
|
|
829
|
+
|
|
570
830
|
**CRITICAL — never background via shell `&`/`wait`** (fixed forge#2466): A single `forgedock run-issue` invocation drives an issue through investigate → build → review → close and routinely runs 30+ minutes. Backgrounding the process at the *shell* level (`cmd &` … `wait`) does not escape the Bash tool's own per-invocation ceiling — `wait` is itself the foreground command the tool watches, and it blocks for the combined duration of every process in the chunk. This made engine-first dispatch effectively dead code: any chunk running longer than the ceiling was always killed, so every run silently fell through to the Agent-spawn fallback below. The fix: dispatch each `forgedock run-issue` invocation as its **own `Bash` tool call with `run_in_background=true`** — the harness's native "start it, don't wait, notify me on completion" primitive — never with shell-level `&`/`wait`. This is exactly the same async model the Agent-spawn-fallback path already uses (and Step 4B's notification-driven completion loop already expects), so one monitoring loop now covers both dispatch styles.
|
|
571
831
|
|
|
572
832
|
```bash
|
|
@@ -634,6 +894,18 @@ if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
|
|
|
634
894
|
if [ "${#DEFERRED_CONCURRENCY_ISSUES[@]}" -gt 0 ]; then
|
|
635
895
|
echo "CONCURRENCY DEFER: ${#DEFERRED_CONCURRENCY_ISSUES[@]} ready issue(s) held back — ${ACTIVE_DISPATCH_COUNT}/${MAX_CONCURRENT} already in flight. Will dispatch as slots free up: ${DEFERRED_CONCURRENCY_ISSUES[*]}"
|
|
636
896
|
fi
|
|
897
|
+
# Filter against the durable board once while forming this engine batch. The tool-call
|
|
898
|
+
# instruction below re-checks immediately before each launch to close the remaining TOCTOU gap.
|
|
899
|
+
CLAIM_SAFE_DISPATCH=()
|
|
900
|
+
for NUM in "${DISPATCH_NOW[@]}"; do
|
|
901
|
+
if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ] && claim_conflicts_with_live_holder "$NUM"; then
|
|
902
|
+
DEFERRED_CLAIM_ISSUES+=("$NUM")
|
|
903
|
+
continue
|
|
904
|
+
fi
|
|
905
|
+
CLAIM_SAFE_DISPATCH+=("$NUM")
|
|
906
|
+
done
|
|
907
|
+
DISPATCH_NOW=("${CLAIM_SAFE_DISPATCH[@]}")
|
|
908
|
+
|
|
637
909
|
echo "Dispatching ${#DISPATCH_NOW[@]} issue(s) this message via forgedock run-issue (headroom was ${HEADROOM})"
|
|
638
910
|
|
|
639
911
|
for NUM in "${DISPATCH_NOW[@]}"; do
|
|
@@ -670,10 +942,10 @@ fi
|
|
|
670
942
|
fi
|
|
671
943
|
```
|
|
672
944
|
|
|
673
|
-
**Dispatch each issue in `DISPATCH_NOW` via its own backgrounded `Bash` call (MANDATORY when `FORGEDOCK_AVAILABLE=true`) — never shell `&`/`wait`.** Issue one `Bash(...)` call per issue in `DISPATCH_NOW`, all in the same message, so they run concurrently within the headroom already computed above:
|
|
945
|
+
**Dispatch each issue in `DISPATCH_NOW` via its own backgrounded `Bash` call (MANDATORY when `FORGEDOCK_AVAILABLE=true`) — never shell `&`/`wait`.** Immediately before each call, run `claim_conflicts_with_live_holder "{NUM}"`; if it returns success, defer that issue rather than dispatching it. Issue one `Bash(...)` call per remaining issue in `DISPATCH_NOW`, all in the same message, so they run concurrently within the headroom already computed above:
|
|
674
946
|
|
|
675
947
|
```
|
|
676
|
-
Bash(command="forgedock run-issue {NUM} --lane {PR_BASE}", run_in_background=true, description="Engine-drive issue #{NUM}")
|
|
948
|
+
Bash(command="FORGE_GIST_CAPABLE=${FORGE_GIST_CAPABLE} forgedock run-issue {NUM} --lane {PR_BASE}", run_in_background=true, description="Engine-drive issue #{NUM}")
|
|
677
949
|
```
|
|
678
950
|
|
|
679
951
|
Capture the task id each call returns into `ENGINE_DISPATCH_MAP[{NUM}]` (declared alongside `AGENT_ISSUE_MAP` below — Step 4B's completion handler uses this map to identify which issue a backgrounded engine-mode `Bash` completion notification belongs to, the same role `AGENT_ISSUE_MAP` plays for `agent_completed` notifications):
|
|
@@ -687,7 +959,16 @@ ENGINE_DISPATCH_MAP[{NUM}] = <task_id returned by Bash(run_in_background=true, .
|
|
|
687
959
|
|
|
688
960
|
If `DISPATCH_NOW` is empty (headroom is 0), do not dispatch any issues this cycle — wait for the next completion notification, exactly like the Agent-spawn path.
|
|
689
961
|
|
|
690
|
-
**Agent-spawn path (fallback when forgedock CLI unavailable)**: When `FORGEDOCK_AVAILABLE=false
|
|
962
|
+
**Agent-spawn path (Claude fallback when forgedock CLI unavailable)**: When `FORGEDOCK_AVAILABLE=false` and the runtime is not OpenCode, spawn Agent sub-agents per issue using the template below. This preserves engine state via the SubagentStop hook even without the CLI.
|
|
963
|
+
|
|
964
|
+
When the runtime is OpenCode, skip this Claude-only `Agent(...)` path entirely.
|
|
965
|
+
Use the native `task` call above for initial and newly-ready issue dispatches.
|
|
966
|
+
Translate only those OpenCode orchestration dispatches to `task(...)` with
|
|
967
|
+
`subagent_type="general"` and `background=true`. Review and remediation are
|
|
968
|
+
load-bearing child operations and must run foreground. Do not claim task-id
|
|
969
|
+
resume support: after a restart, reconcile the latest `FORGE:DISPATCH` record
|
|
970
|
+
with durable GitHub workflow state, then dispatch a fresh work-on continuation
|
|
971
|
+
only when the issue is still non-terminal.
|
|
691
972
|
|
|
692
973
|
**REMINDER: You MUST use the template below verbatim when on the Agent-spawn fallback path. Only fill in `{VARIABLES}`. Do NOT rewrite the agent prompt. Do NOT write custom implementation instructions. The agent MUST invoke `/work-on` via the Skill tool — this is the HARD RULE from the top of this file.**
|
|
693
974
|
|
|
@@ -709,6 +990,8 @@ Agent(
|
|
|
709
990
|
**Repository**: {GH_REPO}
|
|
710
991
|
**Repo path**: {REPO_PATH}
|
|
711
992
|
|
|
993
|
+
**KNOWLEDGE GIST CAPABILITY**: This orchestration already probed it: `{FORGE_GIST_CAPABLE}`. Before invoking `/work-on`, run `export FORGE_GIST_CAPABLE={FORGE_GIST_CAPABLE}`. Do not re-probe or attempt Gist creation when it is `false`.
|
|
994
|
+
|
|
712
995
|
**YOUR MISSION**: Invoke `/work-on` via the Skill tool and let it run to completion. `/work-on` is a self-contained routing loop that handles the ENTIRE pipeline: investigate → build (context → architect → implement → validate) → review (push → PR → /review-pr --auto-merge) → close (project board → trajectory log → worktree cleanup). Do NOT intervene, compensate, or manually close issues — `/work-on` handles everything including issue closure and label updates in its close phase.
|
|
713
996
|
|
|
714
997
|
**CRITICAL — DO NOT STOP EARLY**: /work-on runs as a multi-phase routing loop. Each phase (investigate, build, review, close) returns an intermediate result — these are NOT completion signals. You are NOT done until the issue reaches a terminal state: `workflow:merged`, `workflow:invalid`, `needs-human`, or `workflow:awaiting-merge`. If /work-on returns after only one phase (e.g., investigation), you MUST invoke it again immediately — it will re-read GitHub state and continue to the next phase. Keep invoking /work-on until it reaches a terminal state. Never output 'done' or stop after an intermediate result.
|
|
@@ -721,7 +1004,9 @@ Agent(
|
|
|
721
1004
|
- For satellite repo issues: `Skill(skill='work-on', args='{SATELLITE_PREFIX}:{NUMBER} --under-orchestration')` (prefix from forge.yaml → repos.satellites)
|
|
722
1005
|
- The `--under-orchestration` flag tells `/work-on` to post its phase-entry `FORGE:HEARTBEAT` comments (Phases 0/1/3/5) — this orchestrator's Step 4B.5 stall detector depends on those timestamps. A solo `/work-on` run omits the flag and skips those writes entirely (see `commands/work-on.md` → Orchestration Flag).
|
|
723
1006
|
- NEVER bypass /work-on with manual git/gh commands — the label updates and structured comments are critical for tracking
|
|
724
|
-
- **
|
|
1007
|
+
- **File-backed GitHub bodies — entity scope plus read-back is mandatory**: Never stage a `gh --body-file` body at a generic shared `/tmp` path, including one made by bare `mktemp`, and never hand-roll a root-level path such as `/tmp_invbody_31076.txt`, which can hang unattended cleanup. Prefer the session scratchpad or a repo-relative scratch directory on Windows: a native Windows `gh` may not resolve Git Bash `/tmp` reliably. Create a filename that contains the target issue/PR number and an agent-unique token, then use `mktemp` for its random suffix. Put one caller-chosen marker such as `<!-- FORGE:BODY-INTEGRITY:${NUMBER}_investigator_${AGENT_TOKEN} -->` in the body. After every `gh issue create|edit` or `gh pr create|comment` using `--body-file`, re-read the target object and assert the exact marker is present; a mismatch is a hard error. Unique names reduce collisions, but only read-back detects a collision that substitutes plausible-looking content from another agent. Do not rely on eyeballing. (forge#2843, forge#2855) <!-- allowlist:check-command-side-effects -->
|
|
1008
|
+
- **GitHub secondary rate limit**: If a GitHub API call returns HTTP 403 with `secondary rate limit` in its response, do NOT retry it or start a polling loop. Stop GitHub content creation for this phase, report the status and response body to the orchestrator in your final result, and wait for a later batch resume. Retrying extends the throttle for every sibling.
|
|
1009
|
+
- **Temp files — ALWAYS use `mktemp`, NEVER hand-roll a path**: You are one of several agents running concurrently on this host and you share its `/tmp` with all of them. Any time you stage content in a temp file before passing it to `gh` (e.g. `--body-file`), create that path with `mktemp` (e.g. `BODY_FILE="$(mktemp)"`). Never write a temp file to a single-segment root path such as `/tmp_invbody_31076.txt`: Claude Code treats removing it as dangerous and requires an explicit approval that bypass mode cannot clear, hanging unattended runs. Also never use a fixed literal such as `/tmp/body.md` or `/tmp/issue.json`: a fixed path collides with another concurrently-running agent and can silently overwrite the content you staged (or you can silently overwrite theirs) before either of you reads it back. Safe: `BODY_FILE="$(mktemp)"`. Unsafe: `/tmp_invbody_31076.txt` (a missing slash that creates a root-level path) and `/tmp/body.md` (a fixed path). `mktemp` costs nothing and prevents both failures. (forge#2198, forge#2855)
|
|
725
1010
|
- **`docker cp` — NEVER write into a bind-mounted shared container**: If the project you're working on runs its dev/test containers with a bind mount to the main (non-worktree) checkout, `docker cp` into that container writes through the mount into the main checkout — not your isolated per-issue worktree. Before running `docker cp` into any container, confirm its mount source is your own worktree, not a shared/main one; if you can't confirm that, don't assume an in-container test run reflects your feature branch. (forge#2198)
|
|
726
1011
|
- NEVER target `main` for PRs targeting the default repo. Use `{STAGING_BRANCH}` for fast-lane issues, or `milestone/{slug}` for milestone issues.
|
|
727
1012
|
- Satellite repos (MCP, n8n) have no staging branch — fast-lane PRs go to `main` for those.
|
|
@@ -911,15 +1196,28 @@ fi
|
|
|
911
1196
|
echo "Dispatching ${#DISPATCH_NOW[@]} issue(s) this message (headroom was ${HEADROOM})"
|
|
912
1197
|
```
|
|
913
1198
|
|
|
914
|
-
Spawn `Agent()` for every issue in `DISPATCH_NOW` (not the raw ready set) using the template above. After the batch spawn, increment `ACTIVE_DISPATCH_COUNT` by
|
|
1199
|
+
Immediately before every Claude `Agent()` or OpenCode `task()` call, run `claim_conflicts_with_live_holder "{NUM}"`; when it returns success, append the issue to `DEFERRED_CLAIM_ISSUES[]` and do not make that call. Spawn `Agent()` for every remaining issue in `DISPATCH_NOW` (not the raw ready set) using the template above. After the batch spawn, increment `ACTIVE_DISPATCH_COUNT` by the number actually dispatched — this happens in addition to, not instead of, capturing each returned agent ID into `AGENT_ISSUE_MAP` above.
|
|
915
1200
|
|
|
916
1201
|
If `DISPATCH_NOW` is empty (headroom is 0), do not spawn any agents this cycle — wait for the next `agent_completed` notification, which frees a slot and re-triggers this dispatch computation (Step 4B).
|
|
917
1202
|
|
|
918
1203
|
### Step 4B: Monitor completions and dispatch newly ready issues
|
|
919
1204
|
|
|
920
|
-
You will be automatically notified when each background agent completes — or, for an engine-first dispatch (Step 4A, `FORGEDOCK_AVAILABLE=true`), when a backgrounded `Bash(run_in_background=true, command="forgedock run-issue ...")` call completes.
|
|
1205
|
+
You will be automatically notified when each background agent completes — or, for an engine-first dispatch (Step 4A, `FORGEDOCK_AVAILABLE=true`), when a backgrounded `Bash(run_in_background=true, command="forgedock run-issue ...")` call completes. In OpenCode, a native background `task` first returns a `state="running"` result and later injects a synthetic `<task id="..." state="completed">` or `state="error"` result into this same parent session. The later result is the completion event; the initial running result is not completion. Both Claude notifications and OpenCode task-result events must trigger the same per-issue handling immediately. **Do NOT use `sleep` loops, wait for the slowest sibling, or poll before processing an event.** When one arrives, look up which issue it belongs to — `AGENT_ISSUE_MAP` for a Claude `agent_completed` notification, `ENGINE_DISPATCH_MAP` for a background-Bash notification, or `OPENCODE_DISPATCH_MAP` for an OpenCode task result — and immediately process it exactly the same way regardless of which map resolved it; every check below (`classify_predecessor_state()`, dependent dispatch, stall/staging checks) keys off GitHub labels/state, not which dispatch mechanism produced them. For an OpenCode completion, append this terminal record before releasing capacity:
|
|
1206
|
+
|
|
1207
|
+
```bash
|
|
1208
|
+
if [ "${DRY_RUN:-false}" = "false" ]; then
|
|
1209
|
+
gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:DISPATCH -->
|
|
1210
|
+
\`\`\`json
|
|
1211
|
+
{\"runtime\":\"opencode\",\"child_session_id\":\"{TASK_ID}\",\"attempt\":${ATTEMPT},\"state\":\"{completed|error}\",\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}
|
|
1212
|
+
\`\`\`"
|
|
1213
|
+
fi
|
|
1214
|
+
```
|
|
1215
|
+
|
|
1216
|
+
**Fallback**: if a runtime ever fails to deliver a background completion notification, read the latest `FORGE:DISPATCH` record and use the documented label-state recovery path, but do not convert the normal OpenCode path to foreground tasks or a wave barrier.
|
|
1217
|
+
|
|
1218
|
+
For an OpenCode completion, retain `OPENCODE_DISPATCH_MAP[{NUMBER}]` until the terminal `FORGE:DISPATCH` record above has been posted successfully. Then remove that map entry and release capacity. This makes a restart deterministic: a latest `running` record restores the task-to-issue association, while a latest `completed` or `error` record cannot release the same slot twice.
|
|
921
1219
|
|
|
922
|
-
**Concurrency slot release (MANDATORY — first action on every completion)** <!-- Added: forge#1912 -->: The instant a completion notification arrives — `agent_completed`,
|
|
1220
|
+
**Concurrency slot release (MANDATORY — first action on every completion)** <!-- Added: forge#1912 -->: The instant a completion notification arrives — `agent_completed`, a background-Bash completion, or an OpenCode `task` result with `state="completed"`/`state="error"` — decrement `ACTIVE_DISPATCH_COUNT` by 1 — a worker slot has just freed, regardless of what terminal state the issue ended up in. Do this before any stall-recovery/resume logic below. If that agent is then resumed or fallback-dispatched (item 2 below, or Step 4B.5's stall recovery) because it stalled mid-pipeline rather than truly finishing, re-increment `ACTIVE_DISPATCH_COUNT` when the `Agent(resume=...)` or a fresh OpenCode `task(background=true)` continuation is issued — either re-occupies a worker slot exactly like a fresh dispatch does. (Step 4B.5's TIME-BASED hang detector — an agent that never completes at all, as opposed to one that completes at `workflow:engine-error` — remains resume-specific and out of scope for this fix: an engine-dispatched issue that silently hangs still surfaces only via the standard stall-detection alert, and `forgedock resume-stalled` remains available as a separate manual/scripted recovery path for that case. The completion-triggered case — an engine-dispatched issue that DOES complete at `workflow:engine-error` with empty committed state — is still auto-fallen-back by item 2b below, fixed forge#2743.)
|
|
923
1221
|
|
|
924
1222
|
**Ordering requirement (MANDATORY — prevents transient over-cap)**: For every agent that completed in this notification batch, finish its terminal-state check and, if applicable, its resume re-increment (items 1-2 below) BEFORE computing `dispatch_headroom` for item 5's newly-ready-issue dispatch. Computing headroom before a to-be-resumed agent's re-increment would let a genuinely-still-running agent's freed slot be double-booked — once by a fresh dispatch, once by the resume itself — transiently exceeding `MAX_CONCURRENT`. Process every completed agent's items 1-2 to a decision (terminal vs. resumed) first; only then compute headroom once for the batch's item 5 dispatch.
|
|
925
1223
|
|
|
@@ -980,7 +1278,7 @@ classify_predecessor_state() {
|
|
|
980
1278
|
|
|
981
1279
|
A GATED predecessor whose PR later merges reclassifies to DONE the next time `classify_predecessor_state` runs (its label flips to `workflow:merged`) — this is exactly what the merge-triggered wake check (item 6.6 below) relies on.
|
|
982
1280
|
|
|
983
|
-
**File-overlap edge re-verification** <!-- Added: forge#1904 --> — `classify_predecessor_state()` answers "is the predecessor resolved enough to proceed," but it says nothing about whether a specific Layer 1/2/3 file-overlap edge (`EDGE_KIND`/`EDGE_FILES`, tagged in `phase-3-dependency.md` Step 3C/3D) was ever real. Both were built from a **pre-build guess** — the predecessor's `FORGE:INVESTIGATOR` "Affected Files" list, or a raw issue-body parse if no investigation existed yet. Once a predecessor reaches FAILED
|
|
1281
|
+
**File-overlap edge re-verification** <!-- Added: forge#1904 --> — `classify_predecessor_state()` answers "is the predecessor resolved enough to proceed," but it says nothing about whether a specific Layer 1/2/3 file-overlap edge (`EDGE_KIND`/`EDGE_FILES`, tagged in `phase-3-dependency.md` Step 3C/3D) was ever real. Both were built from a **pre-build guess** — the predecessor's `FORGE:INVESTIGATOR` "Affected Files" list, or a raw issue-body parse if no investigation existed yet. Once a predecessor reaches FAILED, GATED, **or DONE**, that guess must be checked against ground truth (the predecessor's actual PR diff, or the absence of any PR) before the edge is allowed to keep blocking a dependent or to inject a same-file brief. Without this, a predecessor that reaches `needs-human` or `workflow:invalid` having never touched the guessed shared file (or never opened a PR at all) leaves its dependents gated/skipped on a conflict that never existed — and on the happy path, an over-serialized chain is never reconciled at all and stays serialized for its entire life (forge#2848).
|
|
984
1282
|
|
|
985
1283
|
```bash
|
|
986
1284
|
verify_file_overlap_edge() {
|
|
@@ -1010,9 +1308,25 @@ verify_file_overlap_edge() {
|
|
|
1010
1308
|
# a MERGED PR would already classify the predecessor DONE, not FAILED/GATED — so this filter
|
|
1011
1309
|
# also guarantees `gh pr diff` below targets a live, still-open branch rather than an
|
|
1012
1310
|
# abandoned PR whose branch may since have been force-deleted (e.g. by /cleanup).
|
|
1311
|
+
#
|
|
1312
|
+
# forge#2848 — the DONE call site widens what this can surface: for a DONE predecessor
|
|
1313
|
+
# the live PR is MERGED, not OPEN. That is the *better* case, not a problem — a merged
|
|
1314
|
+
# PR's diff is ground truth rather than a still-moving branch, and `gh pr diff` serves it
|
|
1315
|
+
# from the API, so it keeps working after the head branch is deleted on merge. The
|
|
1316
|
+
# CLOSED-unmerged exclusion is unchanged and still correct for all three call sites.
|
|
1013
1317
|
local PRED_PR
|
|
1318
|
+
local PRED_PR_EXIT
|
|
1014
1319
|
PRED_PR=$(gh pr list -R {GH_REPO} --state all --search "\"Closes #${PRED}\" in:body" \
|
|
1015
|
-
--json number,state --jq '[.[] | select(.state != "CLOSED")][0].number // empty' 2>/dev/null
|
|
1320
|
+
--json number,state --jq '[.[] | select(.state != "CLOSED")][0].number // empty' 2>/dev/null)
|
|
1321
|
+
PRED_PR_EXIT=$?
|
|
1322
|
+
|
|
1323
|
+
if [ "$PRED_PR_EXIT" -ne 0 ]; then
|
|
1324
|
+
# Fail-safe: could not determine whether a live predecessor PR exists (transient API
|
|
1325
|
+
# error, rate limit, or search-index lag). Keep the edge rather than conflating a failed
|
|
1326
|
+
# lookup with confirmed absence of a PR and dropping a real conflict.
|
|
1327
|
+
echo "KEEP"
|
|
1328
|
+
return
|
|
1329
|
+
fi
|
|
1016
1330
|
|
|
1017
1331
|
if [ -z "$PRED_PR" ]; then
|
|
1018
1332
|
# Predecessor reached a terminal/gated state having never opened a PR (or only ever had
|
|
@@ -1062,16 +1376,28 @@ verify_file_overlap_edge() {
|
|
|
1062
1376
|
}
|
|
1063
1377
|
```
|
|
1064
1378
|
|
|
1065
|
-
**Call sites**: item 6 (FAILED handling, below)
|
|
1379
|
+
**Call sites**: item 6 (FAILED handling, below), item 6.5 (GATED handling, below), and the DONE arm of the core streaming dispatch loop (below — see "DONE-path edge re-verification") all call this helper — once per `EDGE_KIND`-tagged predecessor edge — before cascading a skip or tracking `blocked-on-human-merge`. `phase-3-dependency.md`'s wake/compaction reconstruction block calls the identical logic (mirrored verbatim, not re-derived) for the case where the predecessor resolves after the orchestrator session has already ended — see that file's "Orchestrator state reconstruction on wake / after compaction" section. This mirroring is deliberate: keeping the check in exactly one function definition, called (not reimplemented) from both files, avoids the same drift class forge#1812 had to fix once already for DONE/GATED/FAILED classification itself.
|
|
1066
1380
|
|
|
1067
1381
|
**Recursive cascade note (item 6 only)**: when a dependent `DEP` is itself marked "skipped — dependency failed" (edge confirmed `KEEP`, not dropped), `DEP` becomes the new FAILED anchor for its own direct dependents — re-run this same `verify_file_overlap_edge` check at that next hop too, rather than assuming every transitive descendant is unconditionally skipped. A grandchild dependent whose only path to the failure runs through a spurious (droppable) edge one hop down must not be skipped just because its parent was.
|
|
1068
1382
|
|
|
1069
|
-
**Never called from**: the
|
|
1383
|
+
**Never called from**: the `IN_PROGRESS` branch (nothing to re-verify yet — the predecessor hasn't concluded).
|
|
1384
|
+
|
|
1385
|
+
**DONE-path edge re-verification** <!-- Added: forge#2848 --> — the DONE branch *does* call this helper, and it is the only call site that fires on the happy path. Be precise about what it buys, because the obvious reading is wrong:
|
|
1386
|
+
|
|
1387
|
+
**It does NOT release the dependent.** A DONE predecessor already satisfies its own edge — see the `;; # satisfied` arm and the `ALL_PREDS_DONE` gate below. Dropping an edge whose predecessor is already DONE releases nobody, so re-verification alone cannot re-parallelize anything. What it actually buys is two things:
|
|
1388
|
+
|
|
1389
|
+
1. **Suppressing a spurious same-file brief.** The DONE arm spends up to three `gh api` comment fetches per edge and then injects a `SAME_FILE_BRIEF` excerpt into the dependent's dispatch context. When the predecessor's real diff never touched the guessed file, that brief is not merely wasted tokens — it actively misleads the agent into coordinating on a file the predecessor never wrote. Verifying **before** those fetches skips all three calls and the brief.
|
|
1390
|
+
2. **Producing the disproof signal that enables re-derivation.** This is the part that actually re-parallelizes. A `DROP` on a `body-fallback`-provenance edge is evidence that the whole cohort's extraction is suspect: the first real diff has just contradicted the guess the chain was built on. By that point the still-blocked descendants have been investigated and possibly contracted, so re-running Layer 1 extraction against those now-higher-provenance sources and dropping edges that no longer hold is what collapses the chain. This is the step an operator performed by hand in the batch that motivated forge#2848; wiring it here makes it automatic.
|
|
1070
1391
|
|
|
1071
1392
|
**Core streaming dispatch loop**: After processing each agent completion, check the DAG for newly unblocked issues. If any issue now has all predecessors classified `DONE`, dispatch it immediately (run Steps 4A.pre.0, 4A.pre, and 4A for the newly ready issues). This is the key difference from the wave model — issues dispatch as soon as their specific predecessors complete, not after an entire group finishes.
|
|
1072
1393
|
|
|
1073
1394
|
```bash
|
|
1074
1395
|
# After each agent completion, check for newly ready issues:
|
|
1396
|
+
READINESS_RESCAN=true
|
|
1397
|
+
while [ "$READINESS_RESCAN" = "true" ]; do
|
|
1398
|
+
# Re-derivation can remove an unresolved predecessor from an issue that this
|
|
1399
|
+
# pass has already visited. Repeat the scan so it can dispatch this cycle.
|
|
1400
|
+
READINESS_RESCAN=false
|
|
1075
1401
|
for BLOCKED_NUM in {all_blocked_issue_numbers}; do
|
|
1076
1402
|
ALL_PREDS_DONE=true
|
|
1077
1403
|
ANY_PRED_GATED=false
|
|
@@ -1089,6 +1415,100 @@ for BLOCKED_NUM in {all_blocked_issue_numbers}; do
|
|
|
1089
1415
|
EDGE_TYPE="${EDGE_KIND["${PRED}:${BLOCKED_NUM}"]:-}"
|
|
1090
1416
|
if [ -n "$EDGE_TYPE" ] && [ -z "${EDGE_BRIEFED["${PRED}:${BLOCKED_NUM}"]:-}" ]; then
|
|
1091
1417
|
EDGE_BRIEFED["${PRED}:${BLOCKED_NUM}"]=1
|
|
1418
|
+
|
|
1419
|
+
# DONE-path edge re-verification (forge#2848). Runs FIRST — before the three
|
|
1420
|
+
# `gh api` brief fetches below — so a disproven edge costs zero extra API calls
|
|
1421
|
+
# and injects no brief. EDGE_BRIEFED is already set above, so a DROP is not
|
|
1422
|
+
# retried on the next completion cycle.
|
|
1423
|
+
#
|
|
1424
|
+
# Fail-safe direction is unchanged: verify_file_overlap_edge returns KEEP on any
|
|
1425
|
+
# fetch failure, so a transient API error can only ever preserve the brief, never
|
|
1426
|
+
# silently suppress a real one.
|
|
1427
|
+
EDGE_VERDICT=$(verify_file_overlap_edge "$PRED" "$BLOCKED_NUM")
|
|
1428
|
+
if [ "$EDGE_VERDICT" = "DROP" ]; then
|
|
1429
|
+
echo "DONE-path re-verification: edge #${PRED} → #${BLOCKED_NUM} (${EDGE_TYPE}, files: ${EDGE_FILES["${PRED}:${BLOCKED_NUM}"]:-?}) DROPPED — #${PRED}'s merged diff never touched the guessed file. Skipping same-file brief."
|
|
1430
|
+
|
|
1431
|
+
# Leave SAME_FILE_BRIEF[$BLOCKED_NUM] UNSET rather than setting it empty —
|
|
1432
|
+
# the dispatch-context builder treats unset as the no-op case, and an empty
|
|
1433
|
+
# string would render an orphaned "shared file context" heading with nothing
|
|
1434
|
+
# under it.
|
|
1435
|
+
|
|
1436
|
+
# Cohort re-derivation (forge#2848) — the step that actually releases
|
|
1437
|
+
# dependents. Gated on `body-fallback` provenance only: a DROP against a
|
|
1438
|
+
# contract- or investigation-derived list is a one-off miss, but a DROP
|
|
1439
|
+
# against a raw-issue-body scrape is evidence the whole cohort's extraction
|
|
1440
|
+
# is suspect (see phase-3-dependency.md Layer 4's cohort-confidence guidance).
|
|
1441
|
+
if [ "${FILE_SOURCE[$PRED]:-}" = "body-fallback" ] && [ -n "$AFFECTED_FILES_SCRIPT" ]; then
|
|
1442
|
+
# Re-derive only direct descendants of this DONE predecessor that
|
|
1443
|
+
# are still blocked now. This intentionally excludes unrelated
|
|
1444
|
+
# blocked issues and descendants whose PRED edge was already removed.
|
|
1445
|
+
STILL_BLOCKED_DESCENDANTS=()
|
|
1446
|
+
for CANDIDATE in {all_blocked_issue_numbers}; do
|
|
1447
|
+
case " ${PREDECESSORS[$CANDIDATE]:-} " in
|
|
1448
|
+
*" $PRED "*) STILL_BLOCKED_DESCENDANTS+=("$CANDIDATE") ;;
|
|
1449
|
+
esac
|
|
1450
|
+
done
|
|
1451
|
+
for DESC in "${STILL_BLOCKED_DESCENDANTS[@]}"; do
|
|
1452
|
+
# Memoize conclusive results per descendant per batch. An inconclusive
|
|
1453
|
+
# extraction gets one retry so a transient failure or pre-contract
|
|
1454
|
+
# attempt does not permanently foreclose re-derivation. The two-attempt
|
|
1455
|
+
# cap preserves an O(descendants) API budget for the hot dispatch loop.
|
|
1456
|
+
[ -n "${EDGE_REDERIVED[$DESC]:-}" ] && continue
|
|
1457
|
+
REDERIVE_ATTEMPTS=${EDGE_REDERIVE_ATTEMPTS[$DESC]:-0}
|
|
1458
|
+
[ "$REDERIVE_ATTEMPTS" -ge 2 ] && continue
|
|
1459
|
+
EDGE_REDERIVE_ATTEMPTS[$DESC]=$((REDERIVE_ATTEMPTS + 1))
|
|
1460
|
+
|
|
1461
|
+
# Re-run Layer 1 extraction. By now DESC has been investigated and
|
|
1462
|
+
# possibly contracted, so this typically returns a higher-provenance
|
|
1463
|
+
# list than the body scrape the original edge was built from. Resolve
|
|
1464
|
+
# the helper through the runtime path, never a bare `bash scripts/...`
|
|
1465
|
+
# (#2794) — same resolver precedence as phase-3-dependency.md Layer 1.
|
|
1466
|
+
REDERIVE_OUT=$(bash "$AFFECTED_FILES_SCRIPT" "$DESC" -R {GH_REPO})
|
|
1467
|
+
REDERIVE_PROV=$(echo "$REDERIVE_OUT" | head -1 | sed 's/^PROVENANCE=//')
|
|
1468
|
+
REDERIVE_FILES=$(echo "$REDERIVE_OUT" | tail -n +2)
|
|
1469
|
+
|
|
1470
|
+
# Fail-safe: only act on a CONFIRMED-empty overlap. `error` means the
|
|
1471
|
+
# extraction was inconclusive (a `gh` call failed — forge#2504), and
|
|
1472
|
+
# `none`/`body-fallback` mean we learned nothing better than what we
|
|
1473
|
+
# already had. In all three cases keep every edge untouched.
|
|
1474
|
+
case "$REDERIVE_PROV" in
|
|
1475
|
+
contract-deliverables|affected-files-section) EDGE_REDERIVED[$DESC]=1 ;;
|
|
1476
|
+
*) echo " re-derivation for #${DESC} inconclusive (provenance: ${REDERIVE_PROV}) — keeping all edges"; continue ;;
|
|
1477
|
+
esac
|
|
1478
|
+
|
|
1479
|
+
# Re-derive only PRED's edge into DESC. PRED is the DONE predecessor
|
|
1480
|
+
# whose merged diff just disproved this edge; other predecessors may
|
|
1481
|
+
# still be building and must retain their serialization edges.
|
|
1482
|
+
for DESC_PRED in "$PRED"; do
|
|
1483
|
+
[ -z "${EDGE_KIND["${DESC_PRED}:${DESC}"]:-}" ] && continue
|
|
1484
|
+
STILL_OVERLAPS=false
|
|
1485
|
+
for EF in ${EDGE_FILES["${DESC_PRED}:${DESC}"]:-}; do
|
|
1486
|
+
[ -z "$EF" ] && continue
|
|
1487
|
+
EF_CLEAN="${EF%/}"
|
|
1488
|
+
EF_ESCAPED=$(printf '%s' "$EF_CLEAN" | sed 's/[.[\*^$()+?{|]/\\&/g')
|
|
1489
|
+
if echo "$REDERIVE_FILES" | grep -qE "(^|/)${EF_ESCAPED}(/|$)"; then
|
|
1490
|
+
STILL_OVERLAPS=true
|
|
1491
|
+
break
|
|
1492
|
+
fi
|
|
1493
|
+
done
|
|
1494
|
+
if [ "$STILL_OVERLAPS" = "false" ]; then
|
|
1495
|
+
echo " re-derived edge #${DESC_PRED} → #${DESC}: DROPPED — '${EDGE_FILES["${DESC_PRED}:${DESC}"]}' absent from #${DESC}'s refreshed ${REDERIVE_PROV} file list"
|
|
1496
|
+
unset 'EDGE_KIND['"${DESC_PRED}:${DESC}"']'
|
|
1497
|
+
unset 'EDGE_FILES['"${DESC_PRED}:${DESC}"']'
|
|
1498
|
+
# Remove DESC_PRED from PREDECESSORS[$DESC] so the ALL_PREDS_DONE
|
|
1499
|
+
# gate below stops waiting on it. DESC becomes dispatch-eligible on
|
|
1500
|
+
# this same cycle if that was its last outstanding predecessor.
|
|
1501
|
+
PREDECESSORS[$DESC]=$(echo "${PREDECESSORS[$DESC]}" | tr ' ' '\n' | grep -vx "$DESC_PRED" | tr '\n' ' ')
|
|
1502
|
+
READINESS_RESCAN=true
|
|
1503
|
+
fi
|
|
1504
|
+
done
|
|
1505
|
+
done
|
|
1506
|
+
fi
|
|
1507
|
+
|
|
1508
|
+
# Edge disproven — nothing further to do for this (PRED, BLOCKED_NUM) pair.
|
|
1509
|
+
continue
|
|
1510
|
+
fi
|
|
1511
|
+
|
|
1092
1512
|
SHARED_FILE="${EDGE_FILES["${PRED}:${BLOCKED_NUM}"]:-}"
|
|
1093
1513
|
|
|
1094
1514
|
# Prefer FORGE:BUILDER (the concrete "what changed" record). Fall back to
|
|
@@ -1140,6 +1560,7 @@ for BLOCKED_NUM in {all_blocked_issue_numbers}; do
|
|
|
1140
1560
|
# Do NOT dispatch. Do NOT mark skipped. Tracked via item 6.5.
|
|
1141
1561
|
fi
|
|
1142
1562
|
done
|
|
1563
|
+
done
|
|
1143
1564
|
# Run Steps 4A.pre.0 → 4A.pre → 4A for newly ready issues. Step 4A's own dispatch-batch
|
|
1144
1565
|
# computation (the HEADROOM/DISPATCH_NOW logic, forge#1912) is what actually caps this —
|
|
1145
1566
|
# "newly ready" here just means "eligible," not "guaranteed to dispatch this cycle." Any
|
|
@@ -1299,11 +1720,7 @@ done
|
|
|
1299
1720
|
# After CLAIM_RELEASED for issue NUM:
|
|
1300
1721
|
# Read all active FORGE:CLAIM annotations from coordination issue
|
|
1301
1722
|
if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_NUM:-}" ]; then
|
|
1302
|
-
|
|
1303
|
-
--jq '[.[] | select(.body | contains("<!-- FORGE:CLAIM -->")) |
|
|
1304
|
-
select(.body | contains("<!-- FORGE:CLAIM_RELEASED -->") | not)] |
|
|
1305
|
-
map({holder: (.body | capture("\\*\\*Holder\\*\\*: (?P<h>[^\\n]+)").h),
|
|
1306
|
-
files: (.body | capture("\\*\\*Files\\*\\*: (?P<f>[^\\n]+)").f)})' 2>/dev/null || echo '[]')
|
|
1723
|
+
ACTIVE_CLAIMS=$(read_active_claims "$COORD_NUM" 2>/dev/null || echo '[]')
|
|
1307
1724
|
# For each still-blocked issue in a Layer-2/4 pair: check if its claim's file set
|
|
1308
1725
|
# is disjoint from all remaining active claims. If so, mark it ready.
|
|
1309
1726
|
# (Layer-1 and Layer-3 edges are never relaxed — this check is Layer-2/4 only.)
|
|
@@ -1367,7 +1784,7 @@ done
|
|
|
1367
1784
|
run_in_background=true,
|
|
1368
1785
|
prompt="You are remediating GitHub PR #{GATING_PR} for the {PROJECT_NAME} project (repo: {GH_REPO}), which is currently held at `needs-human` on its linked issue #{PRED}.
|
|
1369
1786
|
|
|
1370
|
-
**YOUR MISSION**: Invoke `Skill(skill='work-on', args='{GATING_PR} --remediate --issue {PRED} --repo {GH_REPO} --gh-flag {GH_FLAG}')` and let it run to completion. This is a self-contained flow: it checks out the PR branch, fixes any fixable review findings, re-reviews, and either auto-lands the PR, holds it at `workflow:awaiting-merge` for a human, re-escalates back to `needs-human`, or reports the block as policy-level and unfixable. Do NOT intervene manually — do not run raw git/gh commands yourself.
|
|
1787
|
+
**YOUR MISSION**: Invoke `Skill(skill='work-on', args='{GATING_PR} --remediate --issue {PRED} --repo {GH_REPO} --gh-flag {GH_FLAG}')` and let it run to completion. This is a self-contained flow: it classifies the gate first, replaces `needs-human` with `workflow:in-review` only for fixable remediation, checks out the PR branch, fixes any fixable review findings, re-reviews, and either auto-lands the PR, holds it at `workflow:awaiting-merge` for a human, re-escalates back to `needs-human`, or reports the block as policy-level and unfixable. Do NOT intervene manually — do not run raw git/gh commands yourself.
|
|
1371
1788
|
|
|
1372
1789
|
**DO NOT STOP EARLY**: if the Skill call returns without a terminal `REMEDIATE_RESULT.status`, invoke it again — it re-reads GitHub state and resumes. Terminal statuses are: `COMPLETE`, `ALREADY_DONE`, `UNFIXABLE`, `BLOCKED`.
|
|
1373
1790
|
|
|
@@ -1513,6 +1930,7 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
|
|
|
1513
1930
|
```
|
|
1514
1931
|
✓ #{NUMBER} — {title} → PR #{PR} merged to {target}
|
|
1515
1932
|
✗ #{NUMBER} — {title} → {reason for failure}
|
|
1933
|
+
⚠ #{NUMBER} — {title} → PR #{PR} blocked: review panel degraded (full fresh-session re-review required)
|
|
1516
1934
|
⚠ #{NUMBER} — {title} → PIPELINE BYPASS (no /work-on — PR invalid)
|
|
1517
1935
|
⏸ #{NUMBER} — {title} → PR #{PR} awaiting-merge (remediated + re-approved, `main`/deploy-gate base — human merge only, no diagnosis needed; non-`main` bases now auto-land to `workflow:merged` via remediate.md M7, forge#2570)
|
|
1518
1936
|
🔗 #{NUMBER} — {title} → blocked-on-human-merge (gated by #{PRED}, will auto-dispatch on #{PRED} merge)
|
|
@@ -1538,6 +1956,8 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
|
|
|
1538
1956
|
dependent itself has no problem at all, it is simply waiting on someone else's merge. See Phase 6's
|
|
1539
1957
|
"Merge-Ready" report section (`phase-6-report.md` Step 6A.5/6B) for the batch-level rollup.
|
|
1540
1958
|
|
|
1959
|
+
Before printing a terminal status for a PR, inspect its labels. If it has `review-degraded`, print the dedicated blocked line above and retain the issue's `needs-human` state. Never report a degraded review as merged or awaiting-merge.
|
|
1960
|
+
|
|
1541
1961
|
9. **Run staging integrity check** (from Step 4A-pre) if the completed agent merged a PR targeting staging.
|
|
1542
1962
|
|
|
1543
1963
|
**Termination condition**: All issues in the DAG have reached `DONE` or `FAILED` (merged, invalid, or skipped due to dependency failure) — OR are `blocked-on-human-merge` (item 6.5) with no further dispatchable work remaining in the batch. These two outcomes are reported differently: a batch where every issue is `DONE`/`FAILED` is a **clean drain**; a batch where one or more issues remain `blocked-on-human-merge` is a **paused drain** — the active dispatch loop stops (there is nothing left to do until a human merges a gating PR) but this MUST be reported as paused, not as fully complete (see `phase-6-report.md`'s `🔗 Blocked-on-Merge` section). `needs-human` predecessors with no open PR are neither — they remain GATED indefinitely until either a PR appears (dependent moves to blocked-on-human-merge) or the predecessor itself resolves; do not treat isolated `needs-human` issues with no dependents as blocking termination. When either drain condition is met, check whether deferred review-spawned findings exist (accumulated in `DEFERRED_FINDINGS` during Step 4C). If deferred findings exist → proceed to Step 4F (Completion Sweep). If no deferred findings → **call `release_orchestrator_lease()` (Step 4A-pre.-1)** — this is a clean/paused drain of this session's own dispatch loop, so the lease should not be left held for a now-idle session — then proceed to Phase 5. <!-- Added: forge#2627 -->
|
|
@@ -1698,6 +2118,56 @@ that mode only exists as the explicit, one-shot `--include-backlog` opt-in at Ph
|
|
|
1698
2118
|
never silently widen to the full backlog regardless of any flag, since nothing here is a
|
|
1699
2119
|
human-in-the-loop request. <!-- Added: forge#2628 -->
|
|
1700
2120
|
|
|
2121
|
+
### Step 4C.0: Full-repository actionable-issue intake (`policy: all` only)
|
|
2122
|
+
|
|
2123
|
+
An operator-authorised `orchestration.cascade.policy: all` run must also check for newly-created
|
|
2124
|
+
open issues that its own agents did not report and that lack `review-finding`. Run this sweep
|
|
2125
|
+
after every five terminal completions, before the next dispatch cycle. It is not a cascade
|
|
2126
|
+
replacement: Step 4C's `BATCH_T0` filter still governs review-finding recursion.
|
|
2127
|
+
|
|
2128
|
+
At batch start, seed `SEEN_OPEN_ISSUES` with the initial resolved issue set. On each sweep, list
|
|
2129
|
+
the complete open set (`gh issue list --state open --limit 500 --json number,title,labels`), diff
|
|
2130
|
+
it against `SEEN_OPEN_ISSUES`, then mark every returned number seen whether or not it is admitted.
|
|
2131
|
+
For each newly-seen issue, add it to the normal Phase 3 DAG intake only when it has no terminal or
|
|
2132
|
+
in-progress workflow label, `needs-human`, or `workflow:decomposed`; preserve its labels and
|
|
2133
|
+
record why every excluded issue was skipped. Do not filter on `review-finding`, author, creation
|
|
2134
|
+
time, or agent trajectory. This is how CI-created issues enter an all-policy run without silently
|
|
2135
|
+
adopting pre-existing work in other policies.
|
|
2136
|
+
|
|
2137
|
+
```bash
|
|
2138
|
+
if [ "$CASCADE_POLICY_NAME" = "all" ] && [ "$((TERMINAL_COMPLETIONS % 5))" -eq 0 ] && \
|
|
2139
|
+
[ "$TERMINAL_COMPLETIONS" -gt "$FULL_REPO_SWEEP_COUNT" ]; then
|
|
2140
|
+
FULL_REPO_SWEEP_COUNT=$TERMINAL_COMPLETIONS
|
|
2141
|
+
FULL_REPO_OPEN=$(gh issue list -R {GH_REPO} --state open --limit 500 --json number,title,labels)
|
|
2142
|
+
while IFS= read -r ISSUE; do
|
|
2143
|
+
NUM=$(echo "$ISSUE" | jq -r '.number')
|
|
2144
|
+
[ -n "${SEEN_OPEN_ISSUES[$NUM]:-}" ] && continue
|
|
2145
|
+
SEEN_OPEN_ISSUES[$NUM]=1
|
|
2146
|
+
LABELS=$(echo "$ISSUE" | jq -r '[.labels[].name] | join(",")')
|
|
2147
|
+
if echo "$LABELS" | grep -qE '(^|,)(workflow:(merged|invalid|awaiting-merge|building|in-review|decomposed)|needs-human)(,|$)'; then
|
|
2148
|
+
echo "Full-repo intake: #${NUM} seen but not admitted (terminal, in-progress, or human-gated)"
|
|
2149
|
+
continue
|
|
2150
|
+
fi
|
|
2151
|
+
FULL_REPO_INTAKE+=("$NUM")
|
|
2152
|
+
echo "Full-repo intake: admitted newly-seen actionable issue #${NUM}"
|
|
2153
|
+
done < <(echo "$FULL_REPO_OPEN" | jq -c '.[]')
|
|
2154
|
+
# Feed FULL_REPO_INTAKE through the existing Phase 3 extraction/DAG/claims gates.
|
|
2155
|
+
fi
|
|
2156
|
+
```
|
|
2157
|
+
|
|
2158
|
+
### Step 4C.1: Mechanical automated-alert deduplication (`policy: all` opt-in)
|
|
2159
|
+
|
|
2160
|
+
`orchestration.cascade.dedup_automated: true` permits a narrow exception to the normal
|
|
2161
|
+
no-dedup rule for newly-seen automated alerts. Select one lowest-numbered canonical issue and
|
|
2162
|
+
close another issue only when all of these are true: both authors are bot/app accounts; normalized
|
|
2163
|
+
titles are identical; the generator identity is identical; the triggering condition is identical;
|
|
2164
|
+
and the closing comment links the retained canonical issue. Never apply title similarity to a
|
|
2165
|
+
human-authored report, or when generator/trigger provenance is absent or differs. The reference
|
|
2166
|
+
predicate is `canDeduplicateAutomatedAlert()` in `bin/engine/admission.mjs`.
|
|
2167
|
+
|
|
2168
|
+
This opt-in handles mechanically identical alerts, not substantive bug adjudication. All other
|
|
2169
|
+
possible duplicates remain separate and go through investigation.
|
|
2170
|
+
|
|
1701
2171
|
```bash
|
|
1702
2172
|
# Method 1: Read TRAJECTORY comments from completed issues for "Finding issues" row
|
|
1703
2173
|
for NUM in {completed_issue_number}; do
|
|
@@ -1718,13 +2188,37 @@ gh issue list -R {GH_REPO} --state open --search "label:review-finding created:>
|
|
|
1718
2188
|
|
|
1719
2189
|
**If review-finding issues were spawned:**
|
|
1720
2190
|
|
|
2191
|
+
**Amplification accounting (MANDATORY, before cascade admission):** Count every finding created by this batch, including findings later deferred by any cascade rule. After each merged unit, compute `FINDINGS_SPAWNED / MERGED_UNITS`, append the value to `AMPLIFICATION_RATIO_HISTORY`, and print a convergence warning when the latest `CONVERGENCE_WINDOW` observations are all `>= 1.0`. A high ratio is a signal, not a failure: valuable deep review findings can legitimately raise it.
|
|
2192
|
+
|
|
2193
|
+
For each finding, extract its source PR from `**Source**: PR #N` and increment `FINDINGS_BY_SOURCE_PR[N]`. Classify it as a **same-lineage refinement** only when that source PR closes a `review-finding` issue and both that parent finding and this finding name the same affected file; otherwise classify it as **new surface**. Record the finding number in `REFINEMENT_FINDINGS` or `NEW_SURFACE_FINDINGS` respectively. If provenance or a file path cannot be established, classify as new surface conservatively; never suppress it as a refinement.
|
|
2194
|
+
|
|
2195
|
+
When `CASCADE_MAX_AMPLIFICATION` is not `off`, and the current ratio is greater than that ceiling, defer only newly discovered entries in `REFINEMENT_FINDINGS` with reason `amplification bound exceeded (same-lineage refinement)`. Add them to `DEFERRED_FINDINGS` and `DEFERRED_REASONS` so Step 4F re-evaluates them after the batch drains. Do not apply this bound to new-surface findings, P1/P2 findings, or any finding when the option is `off`.
|
|
2196
|
+
|
|
2197
|
+
```bash
|
|
2198
|
+
# Run once for each completed issue before collecting its findings. Only merged
|
|
2199
|
+
# units advance the denominator; invalid/skipped units do not imply delivered work.
|
|
2200
|
+
if gh issue view "$NUM" -R {GH_REPO} --json labels \
|
|
2201
|
+
--jq '[.labels[].name | select(. == "workflow:merged")] | length' | grep -qx '1'; then
|
|
2202
|
+
MERGED_UNITS=$((MERGED_UNITS + 1))
|
|
2203
|
+
AMPLIFICATION_RATIO=$(awk "BEGIN { printf \"%.2f\", $FINDINGS_SPAWNED / $MERGED_UNITS }")
|
|
2204
|
+
AMPLIFICATION_RATIO_HISTORY+=("$AMPLIFICATION_RATIO")
|
|
2205
|
+
if [ "${#AMPLIFICATION_RATIO_HISTORY[@]}" -ge "$CONVERGENCE_WINDOW" ]; then
|
|
2206
|
+
RECENT_RATIOS=("${AMPLIFICATION_RATIO_HISTORY[@]: -$CONVERGENCE_WINDOW}")
|
|
2207
|
+
if printf '%s\n' "${RECENT_RATIOS[@]}" | awk '$1 < 1 { exit 1 }'; then
|
|
2208
|
+
echo "CONVERGENCE WARNING: amplification has remained >= 1.0 for ${CONVERGENCE_WINDOW} merged units (${AMPLIFICATION_RATIO} current). This can be productive review refinement, but the batch is not shrinking."
|
|
2209
|
+
fi
|
|
2210
|
+
fi
|
|
2211
|
+
fi
|
|
2212
|
+
|
|
2213
|
+
```
|
|
2214
|
+
|
|
1721
2215
|
**Cascade control (MANDATORY — run before folding findings into the DAG):**
|
|
1722
2216
|
|
|
1723
2217
|
For each spawned finding, determine whether it should be **executed** or **deferred**:
|
|
1724
2218
|
|
|
1725
2219
|
**Evaluation order** (first matching rule wins):
|
|
1726
2220
|
0. **Batch fully human-gated** (`BATCH_FULLY_GATED == true`, always defer, even for P1/P2) <!-- Added: forge#1814 -->: The original batch (see Step 4B item 6.7) has exhausted into DONE/FAILED/GATED with nothing left `IN_PROGRESS` — the real blockers are the GATED issues, not a lack of dispatchable findings. Dispatching a new review-finding here cannot produce net batch progress; it only inflates the open-issue count while the productive path waits on a human merge. Always defer, checked before generation and priority. Rationale: this is the idle/backpressure policy this issue adds — without it, rule 2 (below) unconditionally executes P1/P2 findings regardless of how gated the rest of the batch is, which is the root cause of the net-negative churn this policy exists to stop. **Configurable** via `orchestration.cascade.defer_on_batch_gated` (default `true`; `false` under `policy: all` — forge#2234).
|
|
1727
|
-
1. **Generation ≥ 2** (always defer, even for P1/P2, for
|
|
2221
|
+
1. **Generation ≥ 2** (always defer from individual autonomous dispatch, even for P1/P2, for mid-run cascade — see scope note below): Finding was spawned by an issue that was itself a review-finding. A P3 finding at or below the finite `orchestration.cascade.batch_max_generation` ceiling (default 2) is retained only as a candidate for the bounded P3 batch pass; it remains deferred if no batch forms. A formed batch records `FORGE:BATCH_MAX_GENERATION` and its generation-2+ members. No P1/P2, or finding above that ceiling, takes this exception. Rationale: gen-2+ individual cascade is theoretically unbounded — cap it here while permitting one auditable aggregation unit. **Scope**: this rule caps *autonomous* cascade — findings discovered and re-triaged automatically during an unattended run. It is not a cap on what a human can explicitly request. When an operator directly asks for cascade/review-finding work via `phase-1-resolve.md`'s dedicated `cascade`/`review-findings`/`findings` resolution (with `--include-deferred`/`--allow-gen2`, or the `orchestration.cascade.max_generation` config lever from #2234), those findings enter the DAG through Phase 1 resolution, not through this Step 4C mid-run triage. `batch_max_generation` is separate from `max_generation`, and remains finite even under `policy: all`. <!-- Added: forge#2231, forge#2849 -->
|
|
1728
2222
|
2. **Priority override** (P1 or P2 → always execute): If the finding is labeled P1 or P2, skip all remaining heuristics and execute. Rationale: high-priority findings must never be suppressed by keyword matching.
|
|
1729
2223
|
3. **Comment/typo heuristic** (P3 and below only): Finding title contains the word "comment" or "typo" (case-insensitive). These are 1-line cosmetic fixes that do not block other work. **Configurable** via `orchestration.cascade.keyword_heuristic` (default `true`; `false` under `policy: all` — forge#2234).
|
|
1730
2224
|
4. **P3 + same-file overlap**: Finding is labeled `P3` AND the file it targets overlaps with ANY file already in the current batch (active or queued in the DAG). Rationale: same-file P3 findings add predecessor edges that serialize agents — one finding per original issue increases wall-clock time with no proportional value. **Configurable** via `orchestration.cascade.p3_same_file_defer` (default `true`; `false` under `policy: all` — forge#2234).
|
|
@@ -1760,6 +2254,22 @@ ALL_BATCH_FILES=$(echo "$ALL_BATCH_FILES" | sort -u | grep -v '^$')
|
|
|
1760
2254
|
```
|
|
1761
2255
|
|
|
1762
2256
|
```bash
|
|
2257
|
+
# Use the same bounded ancestor walk as Phase 1 so batching records actual depth,
|
|
2258
|
+
# not just whether the immediate source is a review finding.
|
|
2259
|
+
compute_finding_generation() {
|
|
2260
|
+
local body="$1" generation=1 hops=0 source_num source_data
|
|
2261
|
+
while [ "$hops" -lt 10 ]; do
|
|
2262
|
+
source_num=$(echo "$body" | grep -ioE 'spawned from issue #[0-9]+|source issue[: #]+[0-9]+' | head -1 | grep -oE '[0-9]+$')
|
|
2263
|
+
[ -z "$source_num" ] && break
|
|
2264
|
+
source_data=$(gh issue view "$source_num" -R {GH_REPO} --json labels,body 2>/dev/null) || break
|
|
2265
|
+
echo "$source_data" | jq -e '[.labels[].name] | index("review-finding")' >/dev/null 2>&1 || break
|
|
2266
|
+
generation=$((generation + 1))
|
|
2267
|
+
body=$(echo "$source_data" | jq -r '.body')
|
|
2268
|
+
hops=$((hops + 1))
|
|
2269
|
+
done
|
|
2270
|
+
echo "$generation"
|
|
2271
|
+
}
|
|
2272
|
+
|
|
1763
2273
|
# For each finding, check its priority label and generation
|
|
1764
2274
|
# NOTE: DEFERRED_FINDINGS, QUEUED_FINDINGS, and DEFERRED_REASONS are declared at
|
|
1765
2275
|
# batch scope in Step 4A.pre — do NOT re-initialize them here (Step 4C runs per-agent).
|
|
@@ -1767,6 +2277,45 @@ for FINDING_NUM in {spawned_finding_numbers}; do
|
|
|
1767
2277
|
FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json labels,title,body,updatedAt \
|
|
1768
2278
|
--jq '{labels: [.labels[].name], title: .title, body: .body, updatedAt: .updatedAt}')
|
|
1769
2279
|
|
|
2280
|
+
# Count each finding once and retain source provenance for the final per-PR
|
|
2281
|
+
# breakdown. Missing source/file evidence fails open as new surface.
|
|
2282
|
+
FINDING_BODY=$(echo "$FINDING_DATA" | jq -r '.body')
|
|
2283
|
+
SOURCE_PR=""
|
|
2284
|
+
if [[ "$FINDING_BODY" =~ \*\*[Ss]ource\*\*:[[:space:]]*[Pp][Rr][[:space:]]*\#([0-9]+) ]]; then
|
|
2285
|
+
SOURCE_PR="${BASH_REMATCH[1]}"
|
|
2286
|
+
fi
|
|
2287
|
+
if [ -z "${AMPLIFICATION_FINDING_SEEN[$FINDING_NUM]:-}" ]; then
|
|
2288
|
+
AMPLIFICATION_FINDING_SEEN[$FINDING_NUM]=1
|
|
2289
|
+
FINDINGS_SPAWNED=$((FINDINGS_SPAWNED + 1))
|
|
2290
|
+
[ -n "$SOURCE_PR" ] && FINDINGS_BY_SOURCE_PR[$SOURCE_PR]=$(( ${FINDINGS_BY_SOURCE_PR[$SOURCE_PR]:-0} + 1 ))
|
|
2291
|
+
fi
|
|
2292
|
+
FINDING_IS_REFINEMENT=false
|
|
2293
|
+
FINDING_FILE_FOR_LINEAGE=$(echo "$FINDING_BODY" | grep -oE '`[^`]+\.(py|tsx?|jsx?|sql|json|ya?ml|sh|md)`' | head -1 | tr -d '`')
|
|
2294
|
+
if [ -n "$SOURCE_PR" ] && [ -n "$FINDING_FILE_FOR_LINEAGE" ]; then
|
|
2295
|
+
SOURCE_ISSUE=""
|
|
2296
|
+
SOURCE_PR_BODY=$(gh pr view "$SOURCE_PR" -R {GH_REPO} --json body --jq '.body' 2>/dev/null || echo "")
|
|
2297
|
+
SOURCE_PR_BODY_LOWER=$(echo "$SOURCE_PR_BODY" | tr '[:upper:]' '[:lower:]')
|
|
2298
|
+
if [[ "$SOURCE_PR_BODY_LOWER" =~ (close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]+\#([0-9]+) ]]; then
|
|
2299
|
+
SOURCE_ISSUE="${BASH_REMATCH[3]}"
|
|
2300
|
+
fi
|
|
2301
|
+
if [ -n "$SOURCE_ISSUE" ]; then
|
|
2302
|
+
PARENT_DATA=$(gh issue view "$SOURCE_ISSUE" -R {GH_REPO} --json labels,body \
|
|
2303
|
+
--jq '{labels: [.labels[].name], body: .body}' 2>/dev/null || echo '{}')
|
|
2304
|
+
PARENT_FILE=$(echo "$PARENT_DATA" | jq -r '.body // ""' | grep -oE '`[^`]+\.(py|tsx?|jsx?|sql|json|ya?ml|sh|md)`' | head -1 | tr -d '`')
|
|
2305
|
+
if echo "$PARENT_DATA" | jq -e '[.labels[] | select(. == "review-finding")] | length > 0' >/dev/null && \
|
|
2306
|
+
[ "$PARENT_FILE" = "$FINDING_FILE_FOR_LINEAGE" ]; then
|
|
2307
|
+
FINDING_IS_REFINEMENT=true
|
|
2308
|
+
REFINEMENT_FINDINGS[$FINDING_NUM]="$SOURCE_PR"
|
|
2309
|
+
else
|
|
2310
|
+
NEW_SURFACE_FINDINGS[$FINDING_NUM]="$SOURCE_PR"
|
|
2311
|
+
fi
|
|
2312
|
+
else
|
|
2313
|
+
NEW_SURFACE_FINDINGS[$FINDING_NUM]="$SOURCE_PR"
|
|
2314
|
+
fi
|
|
2315
|
+
else
|
|
2316
|
+
NEW_SURFACE_FINDINGS[$FINDING_NUM]="${SOURCE_PR:-unknown}"
|
|
2317
|
+
fi
|
|
2318
|
+
|
|
1770
2319
|
# Code-branch repair guard (MANDATORY — before priority/defer heuristics below).
|
|
1771
2320
|
# A review-finding with no **Code branch** annotation, whose parent PR's base is
|
|
1772
2321
|
# not the staging fast lane, would otherwise silently fall through to
|
|
@@ -1831,30 +2380,33 @@ for FINDING_NUM in {spawned_finding_numbers}; do
|
|
|
1831
2380
|
# as its own outcome and fail closed, mirroring the concurrent-edit branch below
|
|
1832
2381
|
# rather than silently falling through to the unprotected write.
|
|
1833
2382
|
echo "REPAIR: #${FINDING_NUM} skipped — freshness re-fetch failed (network error or API rate limit); cannot verify no concurrent edit occurred, failing closed instead of risking a silent clobber"
|
|
1834
|
-
#
|
|
1835
|
-
#
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
GATE_BODY_TMPFILE="$(mktemp)"
|
|
2383
|
+
# Scope the scratch name to both this finding and this orchestrator process.
|
|
2384
|
+
# The marker read-back below detects a plausible-looking body substitution.
|
|
2385
|
+
SCRATCHPAD="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}"
|
|
2386
|
+
AGENT_TOKEN="${AGENT_ID:-${HOSTNAME:-orchestrator}-$$}"
|
|
2387
|
+
mkdir -p "$SCRATCHPAD"
|
|
2388
|
+
GATE_BODY_MARKER="FORGE:BODY-INTEGRITY:${FINDING_NUM}_freshness-gate_${AGENT_TOKEN}"
|
|
2389
|
+
GATE_BODY_TMPFILE="$(mktemp "$SCRATCHPAD/${FINDING_NUM}_freshness-gate_${AGENT_TOKEN}.XXXXXX.md")"
|
|
1842
2390
|
printf '%s' "<!-- FORGE:GATE_FAILURE -->
|
|
1843
2391
|
## Code Branch Repair Skipped — Freshness Re-fetch Failed
|
|
1844
2392
|
|
|
1845
|
-
Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${REPAIR_SOURCE_PR} bases on \`${REPAIR_PARENT_BASE}\` — not the staging fast lane. Automatic repair was skipped because the freshness re-fetch (\`gh issue view --json updatedAt\`) failed, so the concurrency guard could not confirm the issue body is still at the snapshot captured earlier in this iteration (\`${FINDING_UPDATED_AT_SNAPSHOT}\`). Proceeding with the write here would risk silently clobbering a concurrent edit with no way to verify. Human review required to confirm the current body state and, if still needed, re-apply the Code branch stamp manually. <!-- forge#2565 -->
|
|
2393
|
+
Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${REPAIR_SOURCE_PR} bases on \`${REPAIR_PARENT_BASE}\` — not the staging fast lane. Automatic repair was skipped because the freshness re-fetch (\`gh issue view --json updatedAt\`) failed, so the concurrency guard could not confirm the issue body is still at the snapshot captured earlier in this iteration (\`${FINDING_UPDATED_AT_SNAPSHOT}\`). Proceeding with the write here would risk silently clobbering a concurrent edit with no way to verify. Human review required to confirm the current body state and, if still needed, re-apply the Code branch stamp manually. <!-- forge#2565 -->
|
|
2394
|
+
<!-- ${GATE_BODY_MARKER} -->" > "$GATE_BODY_TMPFILE"
|
|
1846
2395
|
gh issue comment "$FINDING_NUM" -R {GH_REPO} --body-file "$GATE_BODY_TMPFILE" 2>/dev/null || true
|
|
2396
|
+
gh api "repos/{GH_REPO}/issues/${FINDING_NUM}/comments" --jq '.[].body' | grep -Fqx "<!-- ${GATE_BODY_MARKER} -->" || { echo "ERROR: freshness-gate comment marker missing" >&2; exit 1; }
|
|
1847
2397
|
rm -f "$GATE_BODY_TMPFILE"
|
|
1848
2398
|
gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label needs-human 2>/dev/null || true # freshness-recheck-failure path
|
|
1849
2399
|
elif [ "$FINDING_UPDATED_AT_CURRENT" != "$FINDING_UPDATED_AT_SNAPSHOT" ]; then
|
|
1850
2400
|
echo "REPAIR: #${FINDING_NUM} skipped — concurrent edit detected (updatedAt changed from ${FINDING_UPDATED_AT_SNAPSHOT} to ${FINDING_UPDATED_AT_CURRENT} since this iteration's initial read); flagging instead of repairing"
|
|
1851
|
-
|
|
1852
|
-
CONCURRENT_EDIT_BODY_TMPFILE="$(mktemp)"
|
|
2401
|
+
CONCURRENT_EDIT_BODY_MARKER="FORGE:BODY-INTEGRITY:${FINDING_NUM}_concurrent-edit_${AGENT_TOKEN}"
|
|
2402
|
+
CONCURRENT_EDIT_BODY_TMPFILE="$(mktemp "$SCRATCHPAD/${FINDING_NUM}_concurrent-edit_${AGENT_TOKEN}.XXXXXX.md")"
|
|
1853
2403
|
printf '%s' "<!-- FORGE:GATE_FAILURE -->
|
|
1854
2404
|
## Code Branch Repair Skipped — Concurrent Edit Detected
|
|
1855
2405
|
|
|
1856
|
-
Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${REPAIR_SOURCE_PR} bases on \`${REPAIR_PARENT_BASE}\` — not the staging fast lane. Automatic repair was skipped because the issue body was edited concurrently (\`updatedAt\` changed from \`${FINDING_UPDATED_AT_SNAPSHOT}\` to \`${FINDING_UPDATED_AT_CURRENT}\` between this run's initial read and the repair write). Overwriting the body now would have silently discarded that concurrent edit. Human review required to reconcile and, if still needed, re-apply the Code branch stamp manually. <!-- forge#2512 -->
|
|
2406
|
+
Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${REPAIR_SOURCE_PR} bases on \`${REPAIR_PARENT_BASE}\` — not the staging fast lane. Automatic repair was skipped because the issue body was edited concurrently (\`updatedAt\` changed from \`${FINDING_UPDATED_AT_SNAPSHOT}\` to \`${FINDING_UPDATED_AT_CURRENT}\` between this run's initial read and the repair write). Overwriting the body now would have silently discarded that concurrent edit. Human review required to reconcile and, if still needed, re-apply the Code branch stamp manually. <!-- forge#2512 -->
|
|
2407
|
+
<!-- ${CONCURRENT_EDIT_BODY_MARKER} -->" > "$CONCURRENT_EDIT_BODY_TMPFILE"
|
|
1857
2408
|
gh issue comment "$FINDING_NUM" -R {GH_REPO} --body-file "$CONCURRENT_EDIT_BODY_TMPFILE" 2>/dev/null || true
|
|
2409
|
+
gh api "repos/{GH_REPO}/issues/${FINDING_NUM}/comments" --jq '.[].body' | grep -Fqx "<!-- ${CONCURRENT_EDIT_BODY_MARKER} -->" || { echo "ERROR: concurrent-edit comment marker missing" >&2; exit 1; }
|
|
1858
2410
|
rm -f "$CONCURRENT_EDIT_BODY_TMPFILE"
|
|
1859
2411
|
gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label needs-human 2>/dev/null || true # concurrent-edit path
|
|
1860
2412
|
else
|
|
@@ -1865,13 +2417,15 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
|
|
|
1865
2417
|
FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json labels,title,body,updatedAt \
|
|
1866
2418
|
--jq '{labels: [.labels[].name], title: .title, body: .body, updatedAt: .updatedAt}')
|
|
1867
2419
|
else
|
|
1868
|
-
|
|
1869
|
-
REPAIR_FAILED_BODY_TMPFILE="$(mktemp)"
|
|
2420
|
+
REPAIR_FAILED_BODY_MARKER="FORGE:BODY-INTEGRITY:${FINDING_NUM}_repair-failure_${AGENT_TOKEN}"
|
|
2421
|
+
REPAIR_FAILED_BODY_TMPFILE="$(mktemp "$SCRATCHPAD/${FINDING_NUM}_repair-failure_${AGENT_TOKEN}.XXXXXX.md")"
|
|
1870
2422
|
printf '%s' "<!-- FORGE:GATE_FAILURE -->
|
|
1871
2423
|
## Code Branch Repair Failed
|
|
1872
2424
|
|
|
1873
|
-
Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${REPAIR_SOURCE_PR} bases on \`${REPAIR_PARENT_BASE}\` — not the staging fast lane. Automatic repair (\`gh issue edit --body\`) failed. Without this annotation, \`/work-on\`'s investigation phase may look for the code on the wrong branch (staging, where it is absent) and misclassify this confirmed finding as invalid. Human review required. <!-- forge#2443 -->
|
|
2425
|
+
Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${REPAIR_SOURCE_PR} bases on \`${REPAIR_PARENT_BASE}\` — not the staging fast lane. Automatic repair (\`gh issue edit --body\`) failed. Without this annotation, \`/work-on\`'s investigation phase may look for the code on the wrong branch (staging, where it is absent) and misclassify this confirmed finding as invalid. Human review required. <!-- forge#2443 -->
|
|
2426
|
+
<!-- ${REPAIR_FAILED_BODY_MARKER} -->" > "$REPAIR_FAILED_BODY_TMPFILE"
|
|
1874
2427
|
gh issue comment "$FINDING_NUM" -R {GH_REPO} --body-file "$REPAIR_FAILED_BODY_TMPFILE" 2>/dev/null || true
|
|
2428
|
+
gh api "repos/{GH_REPO}/issues/${FINDING_NUM}/comments" --jq '.[].body' | grep -Fqx "<!-- ${REPAIR_FAILED_BODY_MARKER} -->" || { echo "ERROR: repair-failure comment marker missing" >&2; exit 1; }
|
|
1875
2429
|
rm -f "$REPAIR_FAILED_BODY_TMPFILE"
|
|
1876
2430
|
gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label needs-human 2>/dev/null || true # repair-failure path
|
|
1877
2431
|
fi
|
|
@@ -1908,7 +2462,8 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
|
|
|
1908
2462
|
# all" sets this to false so an operator draining a backlog is never idle-gated.
|
|
1909
2463
|
if [ "$CASCADE_DEFER_ON_BATCH_GATED" = "true" ] && [ "${BATCH_FULLY_GATED:-false}" = "true" ]; then
|
|
1910
2464
|
DEFER=true; DEFER_REASON="batch fully human-gated — idle policy"
|
|
1911
|
-
# Heuristic 1: Generation check — source issue has review-finding label
|
|
2465
|
+
# Heuristic 1: Generation check — source issue has review-finding label. Always defer
|
|
2466
|
+
# individual dispatch; only bounded P3 aggregation can consume an eligible member.
|
|
1912
2467
|
# NOT gated by orchestration.cascade.max_generation — this is Step 4C's autonomous
|
|
1913
2468
|
# mid-run cascade cap, which stays absolute regardless of config (see forge#2231's
|
|
1914
2469
|
# scope note above and phase-1-resolve.md's Cascade / Review-Finding Resolution
|
|
@@ -1917,9 +2472,17 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
|
|
|
1917
2472
|
elif SOURCE_NUM=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oP '(?i)spawned from issue #\K\d+|source issue[: #]+\K\d+' | head -1) && \
|
|
1918
2473
|
[ -n "$SOURCE_NUM" ] && \
|
|
1919
2474
|
gh issue view $SOURCE_NUM -R {GH_REPO} --json labels --jq '[.labels[].name]' 2>/dev/null | grep -q "review-finding"; then
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
2475
|
+
FINDING_GENERATION=$(compute_finding_generation "$(echo "$FINDING_DATA" | jq -r '.body')")
|
|
2476
|
+
FINDING_GENERATIONS[$FINDING_NUM]="$FINDING_GENERATION"
|
|
2477
|
+
DEFER=true; DEFER_REASON="generation ${FINDING_GENERATION} (source #${SOURCE_NUM} is also a review-finding)"
|
|
2478
|
+
if [ "$PRIORITY" = "P3" ] && [ "$FINDING_GENERATION" -le "$BATCH_MAX_GENERATION" ]; then
|
|
2479
|
+
# Keep this out of individual cascade dispatch, but offer it to the P3 batching
|
|
2480
|
+
# pass below. A singleton remains deferred; only a formed batch is admitted.
|
|
2481
|
+
BATCHABLE_DEFERRED_P3+=("$FINDING_NUM")
|
|
2482
|
+
DEFER_REASON="generation ${FINDING_GENERATION} — eligible for bounded P3 batching only"
|
|
2483
|
+
fi
|
|
2484
|
+
# Priority override: P0/P1 always execute; P2 is eligible for batch planning.
|
|
2485
|
+
elif [ "$PRIORITY" = "P0" ] || [ "$PRIORITY" = "P1" ]; then
|
|
1923
2486
|
DEFER=false
|
|
1924
2487
|
# Heuristic 2: Comment/typo keyword (only applies to P3 and below)
|
|
1925
2488
|
# Gated by CASCADE_KEYWORD_HEURISTIC (orchestration.cascade, forge#2234).
|
|
@@ -1939,6 +2502,20 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
|
|
|
1939
2502
|
DEFER=false
|
|
1940
2503
|
fi
|
|
1941
2504
|
|
|
2505
|
+
[ -n "${FINDING_GENERATIONS[$FINDING_NUM]:-}" ] || FINDING_GENERATIONS[$FINDING_NUM]=1
|
|
2506
|
+
|
|
2507
|
+
# Opt-in only: never defer a new-surface or P1/P2 finding because another
|
|
2508
|
+
# lineage is noisy. Step 4F re-evaluates this explicit deferral after drain.
|
|
2509
|
+
AMPLIFICATION_RATIO=$(awk "BEGIN { if ($MERGED_UNITS) printf \"%.2f\", $FINDINGS_SPAWNED / $MERGED_UNITS; else print 0 }")
|
|
2510
|
+
if [ "$DEFER" = "false" ] && [ "$FINDING_IS_REFINEMENT" = "true" ] && \
|
|
2511
|
+
[ "$PRIORITY" != "P1" ] && [ "$PRIORITY" != "P2" ] && \
|
|
2512
|
+
[ "$CASCADE_MAX_AMPLIFICATION" != "off" ] && \
|
|
2513
|
+
awk "BEGIN { exit !($AMPLIFICATION_RATIO > $CASCADE_MAX_AMPLIFICATION) }"; then
|
|
2514
|
+
DEFER=true
|
|
2515
|
+
DEFER_REASON="amplification bound exceeded (same-lineage refinement; ${AMPLIFICATION_RATIO} > ${CASCADE_MAX_AMPLIFICATION})"
|
|
2516
|
+
AMPLIFICATION_DEFERRED+=("$FINDING_NUM")
|
|
2517
|
+
fi
|
|
2518
|
+
|
|
1942
2519
|
if [ "$DEFER" = "true" ]; then
|
|
1943
2520
|
DEFERRED_FINDINGS+=($FINDING_NUM)
|
|
1944
2521
|
DEFERRED_REASONS[$FINDING_NUM]="$DEFER_REASON"
|
|
@@ -1949,9 +2526,11 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
|
|
|
1949
2526
|
done
|
|
1950
2527
|
```
|
|
1951
2528
|
|
|
1952
|
-
**
|
|
2529
|
+
**Concern-level batching for queued P3 findings (MANDATORY check before dispatch):** <!-- Added: forge#1818 -->
|
|
1953
2530
|
|
|
1954
|
-
Cascade-spawned findings collected within a single `/orchestrate` run
|
|
2531
|
+
Cascade-spawned findings collected within a single `/orchestrate` run must be reconsidered against the complete open, unbatched, undispatched candidate registry, not only `QUEUED_FINDINGS` from this completion cycle. At initial resolution and after every five completions (or whenever `DEFERRED_FINDINGS` reaches `MAX_CONCURRENT`), collect retained ungrouped candidates, new findings, and open batch membership; call `planP3BatchGroups()` with that registry and `batchExclusionReason()` danger-zone inputs. Execute returned `extensions` before new groups, retain `ungrouped` members for the next sweep, and record group kinds or exclusion predicates in the run summary. <!-- Added: forge#2858; extended: forge#2851, forge#2852 -->
|
|
2532
|
+
|
|
2533
|
+
The per-cycle `QUEUED_FINDINGS` loop below remains the action-creation mechanism, but it MUST consume the complete-sweep planner output rather than decide groups from its local same-file map. Do not dispatch a locally ungrouped member until the next periodic sweep has considered it against the retained candidate set.
|
|
1955
2534
|
|
|
1956
2535
|
```bash
|
|
1957
2536
|
# Group QUEUED_FINDINGS by exact affected file, reusing the SAME safety
|
|
@@ -1967,13 +2546,17 @@ Cascade-spawned findings collected within a single `/orchestrate` run never pass
|
|
|
1967
2546
|
unset SURFACE_FILE_MEMBERS
|
|
1968
2547
|
declare -A SURFACE_FILE_MEMBERS
|
|
1969
2548
|
|
|
1970
|
-
#
|
|
2549
|
+
# Include generation-2 P3 findings only as batch candidates. They remain deferred
|
|
2550
|
+
# unless this pass actually forms a bounded batch; no member is individually queued.
|
|
2551
|
+
BATCHING_CANDIDATES=("${QUEUED_FINDINGS[@]}" "${BATCHABLE_DEFERRED_P3[@]}")
|
|
2552
|
+
|
|
2553
|
+
# Defensive cap on gh issue view fan-out. BATCHING_CANDIDATES is already bounded by
|
|
1971
2554
|
# upstream cascade control; this cap holds even if that bound is later loosened,
|
|
1972
2555
|
# so the loop can never scale API calls linearly with cascade-seeded findings. <!-- forge#1836 -->
|
|
1973
2556
|
MAX_BATCH_SCAN=50
|
|
1974
2557
|
SCANNED=0
|
|
1975
2558
|
|
|
1976
|
-
for FINDING_NUM in "${
|
|
2559
|
+
for FINDING_NUM in "${BATCHING_CANDIDATES[@]}"; do
|
|
1977
2560
|
SCANNED=$((SCANNED + 1))
|
|
1978
2561
|
if [ "$SCANNED" -gt "$MAX_BATCH_SCAN" ]; then
|
|
1979
2562
|
echo "Surface-area batching: reached MAX_BATCH_SCAN=$MAX_BATCH_SCAN — remaining findings stay individually queued"
|
|
@@ -1983,7 +2566,8 @@ for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
|
|
|
1983
2566
|
FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json title,body,labels \
|
|
1984
2567
|
--jq '{title: .title, body: .body, labels: [.labels[].name]}')
|
|
1985
2568
|
|
|
1986
|
-
#
|
|
2569
|
+
# Billing is never batched. Security findings are classified below and may
|
|
2570
|
+
# batch only with their exact same class. Same jq test() engine and
|
|
1987
2571
|
# patterns as phase-1-resolve.md's batching rule (single shared mechanism).
|
|
1988
2572
|
# Word-boundary anchored (not bare substrings) and attribution-boilerplate
|
|
1989
2573
|
# (**Confidence**/**Severity**/**Review comment** — see forge#2477 note below
|
|
@@ -2011,9 +2595,9 @@ for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
|
|
|
2011
2595
|
# keyword is not auto-batched) for closing a real bypass — the safe
|
|
2012
2596
|
# direction for a security-relevant exclusion. <!-- forge#2477 -->
|
|
2013
2597
|
echo "$FINDING_DATA" | jq -e '
|
|
2014
|
-
(.title | test("\\b(
|
|
2015
|
-
or ((.body | gsub("(?m)^\\*\\*(?:Confidence\\*\\*: (?:CONFIRMED|LIKELY|POSSIBLE)|Severity\\*\\*: (?:CRITICAL|HIGH|MEDIUM|LOW|INFO)|Review comment\\*\\*: https?://\\S+)$"; "")) | test("## Problem[\\s\\S]{0,500}\\b(
|
|
2016
|
-
or ([.labels[]] | any(. == "
|
|
2598
|
+
(.title | test("\\b(billing|operator-only|manual action required|human action required)\\b"; "i"))
|
|
2599
|
+
or ((.body | gsub("(?m)^\\*\\*(?:Confidence\\*\\*: (?:CONFIRMED|LIKELY|POSSIBLE)|Severity\\*\\*: (?:CRITICAL|HIGH|MEDIUM|LOW|INFO)|Review comment\\*\\*: https?://\\S+)$"; "")) | test("## Problem[\\s\\S]{0,500}\\b(billing|operator-only|manual action required|human action required)\\b"; "i"))
|
|
2600
|
+
or ([.labels[]] | any(. == "billing" or . == "needs-human" or . == "blocked" or . == "operator-only"))
|
|
2017
2601
|
' >/dev/null && continue
|
|
2018
2602
|
|
|
2019
2603
|
# Only P3 findings are eligible (P1/P2 already dispatched individually above).
|
|
@@ -2023,7 +2607,11 @@ for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
|
|
|
2023
2607
|
FINDING_FILE=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oE '`[^`]+\.(py|tsx?|jsx?|sql|json|ya?ml|sh|md)`' | head -1 | tr -d '`')
|
|
2024
2608
|
[ -z "$FINDING_FILE" ] && continue
|
|
2025
2609
|
|
|
2026
|
-
|
|
2610
|
+
SAFETY_CLASS=$(node -e 'import("./bin/engine/admission.mjs").then(({ classifyBatchSafety }) => process.stdout.write(classifyBatchSafety(process.argv[1]) || "routine"))' "$(echo "$FINDING_DATA" | jq -r '.title + "\n" + .body + "\n" + (.labels | join(" "))')")
|
|
2611
|
+
# Same file is not enough for security work: the class key prevents a
|
|
2612
|
+
# credential finding from sharing a batch with injection/auth hardening.
|
|
2613
|
+
SURFACE_KEY="${SAFETY_CLASS}:${FINDING_FILE}"
|
|
2614
|
+
SURFACE_FILE_MEMBERS["$SURFACE_KEY"]="${SURFACE_FILE_MEMBERS[$SURFACE_KEY]} $FINDING_NUM"
|
|
2027
2615
|
done
|
|
2028
2616
|
|
|
2029
2617
|
# For each same-file cluster of 2+, actually CREATE the batch issue (executable —
|
|
@@ -2034,23 +2622,39 @@ for FILE in "${!SURFACE_FILE_MEMBERS[@]}"; do
|
|
|
2034
2622
|
MEMBERS=(${SURFACE_FILE_MEMBERS[$FILE]})
|
|
2035
2623
|
[ "${#MEMBERS[@]}" -ge 2 ] || continue
|
|
2036
2624
|
|
|
2625
|
+
SAFETY_CLASS=${FILE%%:*}
|
|
2626
|
+
SURFACE_FILE=${FILE#*:}
|
|
2627
|
+
|
|
2037
2628
|
# Sanitize the affected-file path before interpolating it into the issue title/body.
|
|
2038
2629
|
# Git filenames can legally carry shell metacharacters (`$()`, backticks, quotes);
|
|
2039
2630
|
# restrict to a validated charset so the value cannot break the gh argument
|
|
2040
2631
|
# boundary from an untrusted issue body. Shared guard with phase-1-resolve.md. <!-- forge#1833, forge#1835 -->
|
|
2041
|
-
SAFE_SURFACE_AREA=$(printf '%s' "$
|
|
2632
|
+
SAFE_SURFACE_AREA=$(printf '%s' "$SURFACE_FILE" | tr -cd 'A-Za-z0-9._/-')
|
|
2042
2633
|
|
|
2043
|
-
echo "Same-run surface-area cluster: ${#MEMBERS[@]}
|
|
2634
|
+
echo "Same-run surface-area cluster: ${#MEMBERS[@]} ${SAFETY_CLASS} findings share $SURFACE_FILE — creating batch issue(s)"
|
|
2044
2635
|
|
|
2045
|
-
#
|
|
2046
|
-
|
|
2047
|
-
|
|
2636
|
+
# Routine batches cap at 8; security-class batches cap at 3 and their body
|
|
2637
|
+
# must state a live-vector or defence-in-depth verdict for every member.
|
|
2638
|
+
BATCH_CAP=8
|
|
2639
|
+
[ "$SAFETY_CLASS" != "routine" ] && BATCH_CAP=3
|
|
2640
|
+
for START in $(seq 0 "$BATCH_CAP" $(( ${#MEMBERS[@]} - 1 ))); do
|
|
2641
|
+
CHUNK=("${MEMBERS[@]:$START:$BATCH_CAP}")
|
|
2048
2642
|
[ "${#CHUNK[@]}" -ge 2 ] || continue
|
|
2049
2643
|
|
|
2050
2644
|
MEMBER_LINES=""
|
|
2645
|
+
BATCH_MEMBER_GENERATION=1
|
|
2646
|
+
GEN2_MEMBER_LINES=""
|
|
2051
2647
|
for M in "${CHUNK[@]}"; do
|
|
2052
2648
|
MTITLE=$(gh issue view "$M" -R {GH_REPO} --json title --jq '.title' 2>/dev/null || echo "")
|
|
2053
2649
|
MEMBER_LINES="${MEMBER_LINES}- [ ] #${M}: ${MTITLE}"$'\n'
|
|
2650
|
+
MEMBER_GENERATION="${FINDING_GENERATIONS[$M]:-1}"
|
|
2651
|
+
if [ "$MEMBER_GENERATION" -gt "$BATCH_MEMBER_GENERATION" ]; then
|
|
2652
|
+
BATCH_MEMBER_GENERATION="$MEMBER_GENERATION"
|
|
2653
|
+
fi
|
|
2654
|
+
if [ "$MEMBER_GENERATION" -ge 2 ]; then
|
|
2655
|
+
GEN2_MEMBER_LINES="${GEN2_MEMBER_LINES}- #${M}: generation ${MEMBER_GENERATION}"$'\n'
|
|
2656
|
+
fi
|
|
2657
|
+
[ "$SAFETY_CLASS" = "routine" ] || MEMBER_LINES="${MEMBER_LINES} - **Verdict**: [ ] live vector [ ] defence-in-depth"$'\n'
|
|
2054
2658
|
done
|
|
2055
2659
|
|
|
2056
2660
|
# Dedup-check with member exclusion (MANDATORY — forge#2432, identical mechanism to
|
|
@@ -2076,10 +2680,8 @@ for FILE in "${!SURFACE_FILE_MEMBERS[@]}"; do
|
|
|
2076
2680
|
continue
|
|
2077
2681
|
fi
|
|
2078
2682
|
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
--label "review-finding,priority:P3,batch" \
|
|
2082
|
-
--body "$(cat <<BATCH_EOF
|
|
2683
|
+
CREATE_TOKEN="forge-create-$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM"
|
|
2684
|
+
CREATE_BODY="$(cat <<BATCH_EOF
|
|
2083
2685
|
## Problem
|
|
2084
2686
|
|
|
2085
2687
|
Batch of P3 review findings in **${SAFE_SURFACE_AREA}** (same file), clustered mid-run by phase-4-execution.md to reduce per-finding pipeline overhead.
|
|
@@ -2089,6 +2691,12 @@ Batch of P3 review findings in **${SAFE_SURFACE_AREA}** (same file), clustered m
|
|
|
2089
2691
|
<!-- FORGE:BATCH_MEMBERS -->
|
|
2090
2692
|
${MEMBER_LINES}<!-- /FORGE:BATCH_MEMBERS -->
|
|
2091
2693
|
|
|
2694
|
+
**Maximum member generation**: ${BATCH_MEMBER_GENERATION}
|
|
2695
|
+
<!-- FORGE:BATCH_MAX_GENERATION: ${BATCH_MEMBER_GENERATION} --> <!-- allowlist:check-spec-markers -->
|
|
2696
|
+
|
|
2697
|
+
**Generation >= 2 members admitted by bounded batching**:
|
|
2698
|
+
${GEN2_MEMBER_LINES:-none}
|
|
2699
|
+
|
|
2092
2700
|
## Acceptance Criteria
|
|
2093
2701
|
|
|
2094
2702
|
- [ ] All member findings addressed or closed as false-positive
|
|
@@ -2097,7 +2705,26 @@ ${MEMBER_LINES}<!-- /FORGE:BATCH_MEMBERS -->
|
|
|
2097
2705
|
|
|
2098
2706
|
<!-- FORGE:BATCHABLE -->
|
|
2099
2707
|
BATCH_EOF
|
|
2100
|
-
)"
|
|
2708
|
+
)"
|
|
2709
|
+
CREATE_BODY="${CREATE_BODY}
|
|
2710
|
+
|
|
2711
|
+
<!-- issue-create-token:${CREATE_TOKEN} -->"
|
|
2712
|
+
CREATE_RESPONSE=$(gh api "repos/{GH_REPO}/issues" --method POST \
|
|
2713
|
+
-f title="$PROPOSED_BATCH_TITLE" -f body="$CREATE_BODY" \
|
|
2714
|
+
-f 'labels[]=review-finding' -f 'labels[]=priority:P3' -f 'labels[]=batch') || {
|
|
2715
|
+
echo "ERROR: GitHub rejected same-run batch creation; members remain queued." >&2
|
|
2716
|
+
continue
|
|
2717
|
+
}
|
|
2718
|
+
BATCH_ISSUE_NUM=$(echo "$CREATE_RESPONSE" | jq -r '.number // empty')
|
|
2719
|
+
if [ -z "$BATCH_ISSUE_NUM" ]; then
|
|
2720
|
+
echo "ERROR: same-run batch creation returned no issue number; members remain queued." >&2
|
|
2721
|
+
continue
|
|
2722
|
+
fi
|
|
2723
|
+
CREATED_BODY=$(gh issue view "$BATCH_ISSUE_NUM" -R {GH_REPO} --json body --jq '.body') || continue
|
|
2724
|
+
if ! printf '%s' "$CREATED_BODY" | grep -qF "issue-create-token:${CREATE_TOKEN}"; then
|
|
2725
|
+
echo "ERROR: batch issue #${BATCH_ISSUE_NUM} failed create-token read-back; members remain queued." >&2
|
|
2726
|
+
continue
|
|
2727
|
+
fi
|
|
2101
2728
|
|
|
2102
2729
|
# Consume the cluster: record members and REPLACE them in QUEUED_FINDINGS
|
|
2103
2730
|
# with the single batch issue, so the dispatch step below operates on the
|
|
@@ -2596,6 +3223,8 @@ The context-gathering phase can fetch this index to discover all investigation G
|
|
|
2596
3223
|
**Repository**: {GH_REPO}
|
|
2597
3224
|
**Repo path**: {REPO_PATH}
|
|
2598
3225
|
|
|
3226
|
+
**KNOWLEDGE GIST CAPABILITY**: This orchestration already probed it: `${FORGE_GIST_CAPABLE}`. Before invoking `/work-on`, run `export FORGE_GIST_CAPABLE=${FORGE_GIST_CAPABLE}`. Do not re-probe or attempt Gist creation when it is `false`.
|
|
3227
|
+
|
|
2599
3228
|
**YOUR MISSION**: Invoke \`/work-on\` via the Skill tool and let it run to completion. \`/work-on\` is a self-contained routing loop that handles the ENTIRE pipeline: investigate → build (context → architect → implement → validate) → review (push → PR → /review-pr --auto-merge) → close (project board → trajectory log → worktree cleanup). Do NOT intervene, compensate, or manually close issues — \`/work-on\` handles everything including issue closure and label updates in its close phase.
|
|
2600
3229
|
|
|
2601
3230
|
**CRITICAL — DO NOT STOP EARLY**: /work-on runs as a multi-phase routing loop. Each phase (investigate, build, review, close) returns an intermediate result — these are NOT completion signals. You are NOT done until the issue reaches a terminal state: \`workflow:merged\`, \`workflow:invalid\`, \`needs-human\`, or \`workflow:awaiting-merge\`. If /work-on returns after only one phase (e.g., investigation), you MUST invoke it again immediately — it will re-read GitHub state and continue to the next phase. Keep invoking /work-on until it reaches a terminal state. Never output 'done' or stop after an intermediate result.
|