From 48fd1df28adab5086dbe02a14b12f5347d4d725d Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 23 Jul 2026 17:08:57 +0000 Subject: [PATCH] fix(ci-queue-wait): treat absent branch (404) as queue-clear (#872) Closes #872 Mos (id-11) Gate-16 merge: independent review APPROVE @23cbdaf8, author jason.woltje(id2) != approver Mos(id11), CI green wp1974. Co-authored-by: jason.woltje Co-committed-by: jason.woltje --- .../framework/tools/git/ci-queue-wait.ps1 | 10 ++ .../framework/tools/git/ci-queue-wait.sh | 20 ++- .../git/test-ci-queue-wait-branch-absent.sh | 153 ++++++++++++++++++ .../framework/tools/orchestrator/README.md | 31 ++-- packages/mosaic/package.json | 2 +- 5 files changed, 202 insertions(+), 14 deletions(-) create mode 100644 packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh diff --git a/packages/mosaic/framework/tools/git/ci-queue-wait.ps1 b/packages/mosaic/framework/tools/git/ci-queue-wait.ps1 index 4e98d805..10b13f0f 100644 --- a/packages/mosaic/framework/tools/git/ci-queue-wait.ps1 +++ b/packages/mosaic/framework/tools/git/ci-queue-wait.ps1 @@ -185,6 +185,16 @@ switch ($platform) { $headSha = ($branchPayload.commit.id | Out-String).Trim() } catch { + # A not-yet-pushed feature branch has no in-flight pipeline, so the + # pre-push queue guard must treat 404 as "queue clear", not crash. + $statusCode = $null + if ($_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + } + if ($statusCode -eq 404) { + Write-Host "[ci-queue-wait] branch $Branch not yet on remote — no in-flight pipeline; queue clear." + exit 0 + } Write-Error "Could not resolve $Branch head SHA from Gitea API." exit 1 } diff --git a/packages/mosaic/framework/tools/git/ci-queue-wait.sh b/packages/mosaic/framework/tools/git/ci-queue-wait.sh index 7fb42d36..f1cd6825 100755 --- a/packages/mosaic/framework/tools/git/ci-queue-wait.sh +++ b/packages/mosaic/framework/tools/git/ci-queue-wait.sh @@ -137,7 +137,21 @@ gitea_get_branch_head_sha() { local branch="$3" local token="$4" local url="https://${host}/api/v1/repos/${repo}/branches/${branch}" - curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c ' + # Capture HTTP status so an absent branch (404) is distinguished from an API + # error. A not-yet-pushed feature branch has no in-flight pipeline, so the + # pre-push queue guard must treat 404 as "queue clear", not crash. + local resp code body + resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url") + code="${resp##*$'\n'}" + body="${resp%$'\n'*}" + if [[ "$code" == "404" ]]; then + echo "__BRANCH_ABSENT__" + return 0 + fi + if [[ "$code" != "200" ]]; then + return 1 + fi + printf '%s' "$body" | python3 -c ' import json, sys data = json.load(sys.stdin) commit = data.get("commit") or {} @@ -219,6 +233,10 @@ elif [[ "$PLATFORM" == "gitea" ]]; then exit 1 } HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN") + if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then + echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear." + exit 0 + fi if [[ -z "$HEAD_SHA" ]]; then echo "Error: Could not resolve ${BRANCH} head SHA." >&2 exit 1 diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh new file mode 100644 index 00000000..f6d7f099 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Regression harness for ci-queue-wait.sh's 404-branch-absent handling. +# +# gitea_get_branch_head_sha() resolves a branch's head SHA before the +# pre-push queue guard runs. A branch that has never been pushed doesn't +# exist on the remote yet, so Gitea's branches/ endpoint 404s. +# Before the fix, `curl -fsSL` failed on the 404, its empty stdout was piped +# into `python3 -c 'json.load(sys.stdin)'`, and the resulting +# JSONDecodeError crashed the guard -- blocking every new feature branch's +# first push. The fix must treat 404 as "no in-flight pipeline" (queue +# clear) while still failing closed on a genuine API error. +# +# Covers: +# (a) 404 branch-absent -> exit 0, "queue clear" message. +# (b) 200 existing branch + a terminal CI state -> unchanged behavior. +# (c) genuine API error (500) -> still fail-closed (nonzero exit). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-branch-absent}" +REPO_DIR="$WORK_DIR/repo" +STUB_DIR="$WORK_DIR/stubs" + +rm -rf "$WORK_DIR" +mkdir -p "$REPO_DIR" "$STUB_DIR" + +git -C "$REPO_DIR" init -q +git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git + +# Minimal curl stub. Selects a canned response by inspecting which Gitea +# endpoint is being hit (branches/ vs commits//status) and +# whether -w '%{http_code}' was requested. Only the patched branch-lookup +# call passes -w; the unpatched call and the (unchanged) status call both +# use plain `curl -fsSL` semantics -- exit nonzero and print nothing on a +# non-2xx response. This lets the same stub exercise both the pre-fix and +# post-fix branch-lookup code paths faithfully. +cat > "$STUB_DIR/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +has_w=0 +url="" +for arg in "$@"; do + case "$arg" in + -w) has_w=1 ;; + http://*|https://*) url="$arg" ;; + esac +done + +case "$url" in + */branches/*) mode="${MOSAIC_STUB_BRANCH_MODE:?MOSAIC_STUB_BRANCH_MODE not set}" ;; + */status) mode="${MOSAIC_STUB_STATUS_MODE:-terminal-success}" ;; + *) + echo "curl stub: unrecognized URL: $url" >&2 + exit 2 + ;; +esac + +case "$mode" in + 404) code=404; body="" ;; + 200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;; + 500) code=500; body='{"message":"internal server error"}' ;; + no-status) code=200; body='{}' ;; + terminal-success) code=200; body='{"state":"success"}' ;; + *) + echo "curl stub: unknown mode=$mode" >&2 + exit 2 + ;; +esac + +if [[ "$has_w" == 1 ]]; then + printf '%s\n%s' "$body" "$code" + exit 0 +fi + +# Unpatched branch-lookup call / status-endpoint call: real curl -fsSL +# exits nonzero and emits nothing on stdout for a non-2xx response. +if [[ "$code" != "200" ]]; then + exit 22 +fi +printf '%s' "$body" +SH +chmod +x "$STUB_DIR/curl" + +run_ci_queue_wait() { + local branch="$1" + ( + cd "$REPO_DIR" + export PATH="$STUB_DIR:$PATH" + export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" + export GITEA_TOKEN="stub-token" + export GITEA_URL="https://git.example.test" + "$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1 + ) +} + +fail=0 + +# (a) 404 branch-absent -> queue clear, exit 0. +set +e +out_a=$(MOSAIC_STUB_BRANCH_MODE=404 run_ci_queue_wait "feat/not-pushed-yet" 2>&1) +status_a=$? +set -e +if [[ "$status_a" -ne 0 ]]; then + echo "FAIL(a): expected exit 0 for 404 branch-absent, got $status_a" >&2 + echo "$out_a" >&2 + fail=1 +elif [[ "$out_a" != *"queue clear"* ]]; then + echo "FAIL(a): expected a queue-clear message, got:" >&2 + echo "$out_a" >&2 + fail=1 +fi + +# (b) 200 existing branch + terminal CI state -> unchanged behavior, exit 0. +set +e +out_b=$(MOSAIC_STUB_BRANCH_MODE=200 MOSAIC_STUB_STATUS_MODE=terminal-success run_ci_queue_wait "main" 2>&1) +status_b=$? +set -e +if [[ "$status_b" -ne 0 ]]; then + echo "FAIL(b): expected exit 0 for existing branch with terminal status, got $status_b" >&2 + echo "$out_b" >&2 + fail=1 +elif [[ "$out_b" != *"sha=deadbeefcafef00d0123456789abcdef01234567"* ]]; then + echo "FAIL(b): expected the resolved HEAD SHA to be logged, got:" >&2 + echo "$out_b" >&2 + fail=1 +elif [[ "$out_b" == *"queue clear"* ]]; then + echo "FAIL(b): an existing branch must not take the branch-absent path" >&2 + echo "$out_b" >&2 + fail=1 +fi + +# (c) genuine API error (500) -> still fail-closed, exit nonzero. +set +e +out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1) +status_c=$? +set -e +if [[ "$status_c" -eq 0 ]]; then + echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2 + echo "$out_c" >&2 + fail=1 +elif [[ "$out_c" == *"queue clear"* ]]; then + echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2 + echo "$out_c" >&2 + fail=1 +fi + +if [[ "$fail" -eq 0 ]]; then + echo "ci-queue-wait branch-absent regression passed (3/3 cases)" +fi + +exit "$fail" diff --git a/packages/mosaic/framework/tools/orchestrator/README.md b/packages/mosaic/framework/tools/orchestrator/README.md index 3ee7f104..3d3bb132 100644 --- a/packages/mosaic/framework/tools/orchestrator/README.md +++ b/packages/mosaic/framework/tools/orchestrator/README.md @@ -4,18 +4,18 @@ Helper scripts for r0 coordinator / orchestrator sessions — mission lifecycle, session health, continuation, and board maintenance. See `framework/guides/ORCHESTRATOR-PROTOCOL.md` for the surrounding process. -| Script | Purpose | -|--------|---------| -| `mission-init.sh` | Initialize a new orchestration mission (manifest, scratchpad, TASKS.md). | -| `mission-status.sh` | Show the mission progress dashboard. | -| `session-run.sh` | Generate continuation context and launch the target runtime. | -| `session-resume.sh` | Crash recovery for dead orchestrator sessions. | -| `session-status.sh` | Check agent session health. | -| `continue-prompt.sh` | Generate the continuation prompt for the next session. | -| `board-roll.sh` | Keep a LIVE orchestration board under its byte cap by rolling the oldest entries to its LEDGER. | -| `smoke-test.sh` | Behavior smoke checks for the coord continue/run workflows. | -| `test-board-roll.sh` | Regression harness for `board-roll.sh`. | -| `_lib.sh` | Shared functions sourced by the above (state files, TASKS.md parsing, locks). | +| Script | Purpose | +| -------------------- | ----------------------------------------------------------------------------------------------- | +| `mission-init.sh` | Initialize a new orchestration mission (manifest, scratchpad, TASKS.md). | +| `mission-status.sh` | Show the mission progress dashboard. | +| `session-run.sh` | Generate continuation context and launch the target runtime. | +| `session-resume.sh` | Crash recovery for dead orchestrator sessions. | +| `session-status.sh` | Check agent session health. | +| `continue-prompt.sh` | Generate the continuation prompt for the next session. | +| `board-roll.sh` | Keep a LIVE orchestration board under its byte cap by rolling the oldest entries to its LEDGER. | +| `smoke-test.sh` | Behavior smoke checks for the coord continue/run workflows. | +| `test-board-roll.sh` | Regression harness for `board-roll.sh`. | +| `_lib.sh` | Shared functions sourced by the above (state files, TASKS.md parsing, locks). | ## board-roll.sh @@ -35,16 +35,23 @@ always-current `##` sections) is pinned and never touched: ```markdown # MOS ORCHESTRATION BOARD — LIVE state + > protocol blockquote … (pinned) ## 🟦 Curated always-current section (pinned) + … + ### 2026-07-22 (mid²²) — newest tick, stays longest + … + ### 2026-07-20 (dawn) — oldest tick, rolled first + … + ``` diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 7f6917ee..1235a350 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh" + "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*",