From 5bbb9fc1b381b854f5830d920b4ba186283d6b13 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 00:57:22 +0700 Subject: [PATCH 1/2] claw: claw-t27f, claw-suggest, claw-refresh pass with a note when the App token cannot be minted; claw-refresh lists PRs 25 a page (#10576) claw-refresh was red on every push to master: its GraphQL listing asked for 100 open PRs with mergeStateStatus a page, and with 207 open the second page outran GitHub's timeout (an HTML 502, "invalid character '<'"). 25 a page lists all 207 in the same time. claw-t27f, claw-suggest and claw-refresh now have the #10568 shape: the token step is continue-on-error, a failure step writes the owner's fix to the step summary, and later steps run only with a token. claw-batch is operator- dispatched and stays red on purpose, marked `# token_shape: red` for the law in specs/claw/token_shape.t27 (#10577). Shrink offset: scripts/mcp-wrapper.sh (-10), ported to specs/port/scripts/mcp-wrapper.t27 and sealed; nothing runs it. Refs #10576 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/gate-topology.yml | 29 ++++++++++++++++------------- scripts/mcp-wrapper.sh | 10 ---------- 2 files changed, 16 insertions(+), 23 deletions(-) delete mode 100755 scripts/mcp-wrapper.sh diff --git a/.github/workflows/gate-topology.yml b/.github/workflows/gate-topology.yml index 1ae64d499c..599b1851f2 100644 --- a/.github/workflows/gate-topology.yml +++ b/.github/workflows/gate-topology.yml @@ -88,10 +88,11 @@ jobs: - id: have env: {K: "${{ secrets.CLAW_APP_PRIVATE_KEY }}", I: "${{ vars.CLAW_APP_ID }}"} run: if [ -n "$K" ] && [ -n "$I" ]; then echo ok=1 >> "$GITHUB_OUTPUT"; else echo "claw/t27f posted nothing - secret CLAW_APP_PRIVATE_KEY or variable CLAW_APP_ID is not set (docs/claw/README.md)." >> "$GITHUB_STEP_SUMMARY"; fi - - {id: tok, if: steps.have.outputs.ok, uses: actions/create-github-app-token@v2, with: {app-id: "${{ vars.CLAW_APP_ID }}", private-key: "${{ secrets.CLAW_APP_PRIVATE_KEY }}", owner: gHashTag, permission-checks: write, permission-pull-requests: read, permission-contents: read}} - - {if: steps.have.outputs.ok, uses: actions/checkout@v6} - - {if: steps.have.outputs.ok, uses: sigstore/cosign-installer@v3} - - if: steps.have.outputs.ok + - {id: tok, if: steps.have.outputs.ok, continue-on-error: true, uses: actions/create-github-app-token@v2, with: {app-id: "${{ vars.CLAW_APP_ID }}", private-key: "${{ secrets.CLAW_APP_PRIVATE_KEY }}", owner: gHashTag, permission-checks: write, permission-pull-requests: read, permission-contents: read}} + - {if: steps.tok.outcome == 'failure', run: echo "claw/t27f posted nothing - the TRI CLAW App could not mint a token with checks write; grant it and accept it on the installation - docs/claw/README.md steps 1-2." >> "$GITHUB_STEP_SUMMARY"} + - {if: steps.tok.outputs.token, uses: actions/checkout@v6} + - {if: steps.tok.outputs.token, uses: sigstore/cosign-installer@v3} + - if: steps.tok.outputs.token env: {GH_TOKEN: "${{ steps.tok.outputs.token }}", V: t27o-v0.1.0} shell: bash {0} run: | @@ -151,9 +152,10 @@ jobs: - id: have env: {K: "${{ secrets.CLAW_APP_PRIVATE_KEY }}", I: "${{ vars.CLAW_APP_ID || secrets.CLAW_APP_ID }}"} run: if [ -n "$K" ] && [ -n "$I" ]; then echo ok=1 >> "$GITHUB_OUTPUT"; else echo "claw/suggest posted nothing - secret CLAW_APP_PRIVATE_KEY or CLAW_APP_ID (variable or secret) is not set (docs/claw/README.md)." >> "$GITHUB_STEP_SUMMARY"; fi - - {id: tok, if: steps.have.outputs.ok, uses: actions/create-github-app-token@v2, with: {app-id: "${{ vars.CLAW_APP_ID || secrets.CLAW_APP_ID }}", private-key: "${{ secrets.CLAW_APP_PRIVATE_KEY }}", owner: gHashTag, repositories: t27, permission-pull-requests: write, permission-contents: read}} - - {if: steps.have.outputs.ok, uses: actions/checkout@v6, with: {ref: "${{ github.event.pull_request.head.sha }}", persist-credentials: false}} - - if: steps.have.outputs.ok + - {id: tok, if: steps.have.outputs.ok, continue-on-error: true, uses: actions/create-github-app-token@v2, with: {app-id: "${{ vars.CLAW_APP_ID || secrets.CLAW_APP_ID }}", private-key: "${{ secrets.CLAW_APP_PRIVATE_KEY }}", owner: gHashTag, repositories: t27, permission-pull-requests: write, permission-contents: read}} + - {if: steps.tok.outcome == 'failure', run: echo "claw/suggest posted nothing - the TRI CLAW App could not mint a token with pull_requests write; grant it and accept it on the installation - docs/claw/README.md steps 1-2." >> "$GITHUB_STEP_SUMMARY"} + - {if: steps.tok.outputs.token, uses: actions/checkout@v6, with: {ref: "${{ github.event.pull_request.head.sha }}", persist-credentials: false}} + - if: steps.tok.outputs.token env: {GH_TOKEN: "${{ steps.tok.outputs.token }}", R: "${{ github.repository }}", N: "${{ github.event.pull_request.number }}", S: "${{ github.event.pull_request.head.sha }}"} run: | printf '#include "gen/c/claw/suggest_feed.c"\n#include \n#include \nint main(void){size_t k=1<<24,n;uint8_t*b=malloc(k),*o=malloc(k);uint32_t s[9];n=fread(b,1,k,stdin);if(n==k)return 3;size_t m=suggest_review(b,n,o,k,s);fprintf(stderr,"claw/suggest: posted %%u, outside the diff %%u, over the caps %%u, too big %%u, unreadable %%u, files %%u, refused %%u, bad input %%u, truncated %%u\\n",s[0],s[1],s[2],s[3],s[4],s[5],s[6],s[7],s[8]);if(m>k)return 4;fwrite(o,1,m,stdout);return 0;}\n' | cc -O1 -w -I . -x c -o /tmp/feed - @@ -182,14 +184,15 @@ jobs: - id: have env: {K: "${{ secrets.CLAW_APP_PRIVATE_KEY }}", I: "${{ vars.CLAW_APP_ID || secrets.CLAW_APP_ID }}"} run: if [ -n "$K" ] && [ -n "$I" ]; then echo ok=1 >> "$GITHUB_OUTPUT"; else echo "claw/refresh updated nothing - secret CLAW_APP_PRIVATE_KEY or CLAW_APP_ID (variable or secret) is not set (docs/claw/README.md)." >> "$GITHUB_STEP_SUMMARY"; fi - - {id: tok, if: steps.have.outputs.ok, uses: actions/create-github-app-token@v2, with: {app-id: "${{ vars.CLAW_APP_ID || secrets.CLAW_APP_ID }}", private-key: "${{ secrets.CLAW_APP_PRIVATE_KEY }}", owner: gHashTag, repositories: t27, permission-pull-requests: write}} - - {if: steps.have.outputs.ok, uses: actions/checkout@v6, with: {persist-credentials: false}} - - if: steps.have.outputs.ok + - {id: tok, if: steps.have.outputs.ok, continue-on-error: true, uses: actions/create-github-app-token@v2, with: {app-id: "${{ vars.CLAW_APP_ID || secrets.CLAW_APP_ID }}", private-key: "${{ secrets.CLAW_APP_PRIVATE_KEY }}", owner: gHashTag, repositories: t27, permission-pull-requests: write}} + - {if: steps.tok.outcome == 'failure', run: echo "claw/refresh updated nothing - the TRI CLAW App could not mint a token with pull_requests write; grant it and accept it on the installation - docs/claw/README.md steps 1-2." >> "$GITHUB_STEP_SUMMARY"} + - {if: steps.tok.outputs.token, uses: actions/checkout@v6, with: {persist-credentials: false}} + - if: steps.tok.outputs.token env: {GH_TOKEN: "${{ github.token }}", CLAW_TOKEN: "${{ steps.tok.outputs.token }}", R: "${{ github.repository }}"} run: | printf '#include "gen/c/claw/refresh.c"\n#include \n#include \nint main(void){size_t k=1<<22,n;uint8_t*b=malloc(k),*o=malloc(k);n=fread(b,1,k,stdin);if(n==k)return 3;size_t m=refresh_plan(b,n,o,k);if(m>k)return 4;fwrite(o,1,m,stdout);return 0;}\n' | cc -O1 -w -I . -x c -o /tmp/feed - - list() { gh api graphql --paginate -F owner="${R%/*}" -F name="${R#*/}" -f query='query($owner:String!,$name:String!,$endCursor:String){repository(owner:$owner,name:$name){pullRequests(states:OPEN,first:100,after:$endCursor,orderBy:{field:CREATED_AT,direction:ASC}){pageInfo{hasNextPage endCursor} nodes{number headRefOid createdAt isDraft isCrossRepository maintainerCanModify mergeable mergeStateStatus baseRefName headRepository{nameWithOwner} labels(first:50){nodes{name}}}}}}' --jq '.data.repository.pullRequests.nodes[]|[.number,.headRefOid,.createdAt,.isDraft,([.labels.nodes[].name]|join(",")),.isCrossRepository,(.headRepository.nameWithOwner//""),.maintainerCanModify,.mergeable,.mergeStateStatus,.baseRefName]|@tsv'; } - # GitHub computes mergeability lazily after a push: ask once, wait, ask again. + list() { gh api graphql --paginate -F owner="${R%/*}" -F name="${R#*/}" -f query='query($owner:String!,$name:String!,$endCursor:String){repository(owner:$owner,name:$name){pullRequests(states:OPEN,first:25,after:$endCursor,orderBy:{field:CREATED_AT,direction:ASC}){pageInfo{hasNextPage endCursor} nodes{number headRefOid createdAt isDraft isCrossRepository maintainerCanModify mergeable mergeStateStatus baseRefName headRepository{nameWithOwner} labels(first:50){nodes{name}}}}}}' --jq '.data.repository.pullRequests.nodes[]|[.number,.headRefOid,.createdAt,.isDraft,([.labels.nodes[].name]|join(",")),.isCrossRepository,(.headRepository.nameWithOwner//""),.maintainerCanModify,.mergeable,.mergeStateStatus,.baseRefName]|@tsv'; } + # GitHub computes mergeability lazily after a push: ask once, wait, ask again. Pages of 25: at 100 the second page outran the GraphQL timeout with 207 open PRs and every push went red (#10576). list > /dev/null sleep 30 list > /tmp/prs.tsv @@ -217,7 +220,7 @@ jobs: runs-on: ubuntu-24.04 permissions: {contents: read} steps: - - {id: tok, uses: actions/create-github-app-token@v2, with: {app-id: "${{ vars.CLAW_APP_ID || secrets.CLAW_APP_ID }}", private-key: "${{ secrets.CLAW_APP_PRIVATE_KEY }}", owner: gHashTag, repositories: t27, permission-contents: write, permission-pull-requests: write}} + - {id: tok, uses: actions/create-github-app-token@v2, with: {app-id: "${{ vars.CLAW_APP_ID || secrets.CLAW_APP_ID }}", private-key: "${{ secrets.CLAW_APP_PRIVATE_KEY }}", owner: gHashTag, repositories: t27, permission-contents: write, permission-pull-requests: write}} # token_shape: red - an operator asked for this batch; a token that cannot be minted must fail the run, not hide in a note - {uses: actions/checkout@v6, with: {ref: master, fetch-depth: 0, token: "${{ steps.tok.outputs.token }}"}} - env: {GH_TOKEN: "${{ steps.tok.outputs.token }}", PRS: "${{ inputs.prs }}", SLUG: "${{ steps.tok.outputs.app-slug }}", RUN: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", B: "claw/batch-${{ github.run_id }}"} shell: bash {0} diff --git a/scripts/mcp-wrapper.sh b/scripts/mcp-wrapper.sh deleted file mode 100755 index db174ec6a5..0000000000 --- a/scripts/mcp-wrapper.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -# Trinity MCP server wrapper - routes to traceability server -set -euo pipefail - -# Resolve repo root portably (no hardcoded user path). -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || cd "$SCRIPT_DIR/.." && pwd)" -cd "$REPO_ROOT" - -exec node scripts/mcp-traceability-server.js "$@" From c0faf17991548d8aeb5e35146f6c49696199f1e6 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 01:15:09 +0700 Subject: [PATCH 2/2] claw: refresh is held to the Actions queue -- one merge refreshes at most (100 - queued) / 25 pull requests (#10588) With the GraphQL listing healed, claw-refresh would update up to 30 behind pull requests after every push to master. Each moved head starts about 25 workflow runs, so one merge could queue 750 runs while 886 were already waiting. The master ruleset is not strict, so a refresh only buys freshness: it never unblocks a merge. specs/claw/refresh.t27: - refresh_budget(queued) refreshes nothing at QUEUE_ROOM (100) or more queued runs. Below that it allows (QUEUE_ROOM - queued) / RUNS_PER_REFRESH (25), never more than REFRESH_CAP. An unread count refreshes none. - refresh_plan_queued is what the job calls. It adds one summary line saying which case applied. - refresh_plan_within carries the old body with the cap passed in; refresh_plan keeps its meaning (REFRESH_CAP). - Six new cases: a budget sweep over -1..1000, a busy queue, a quiet queue, an unread queue, a limit above the cap, and the text pins. - Lab results: 50/50 passing, 0 vacuous. Three negative controls fail their cases as they should: cap not lowered, room check removed, unread branch removed. gate-topology.yml claw-refresh: - reads actions/runs?status=queued total_count (permissions gain actions: read) before the listing loop; - passes the count to the harness, which parses it strictly (empty, non-numeric, negative or > i32 is unread); - these inputs were run through the harness on the lab: 886, 0, 30, empty, abc, -, -3 and 99999999999. gen/c/claw/refresh.c regenerated; seal claw_ClawRefresh.json remade. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/gate-topology.yml | 8 +- .trinity/seals/claw_ClawRefresh.json | 20 ++-- gen/c/claw/refresh.c | 159 ++++++++++++++++++++++++++- specs/claw/refresh.t27 | 141 +++++++++++++++++++++++- 4 files changed, 310 insertions(+), 18 deletions(-) diff --git a/.github/workflows/gate-topology.yml b/.github/workflows/gate-topology.yml index 599b1851f2..74da76d822 100644 --- a/.github/workflows/gate-topology.yml +++ b/.github/workflows/gate-topology.yml @@ -178,7 +178,7 @@ jobs: claw-refresh: if: github.event_name == 'push' runs-on: ubuntu-24.04 - permissions: {contents: read, pull-requests: read} + permissions: {contents: read, pull-requests: read, actions: read} concurrency: {group: claw-refresh, cancel-in-progress: true} steps: - id: have @@ -190,18 +190,20 @@ jobs: - if: steps.tok.outputs.token env: {GH_TOKEN: "${{ github.token }}", CLAW_TOKEN: "${{ steps.tok.outputs.token }}", R: "${{ github.repository }}"} run: | - printf '#include "gen/c/claw/refresh.c"\n#include \n#include \nint main(void){size_t k=1<<22,n;uint8_t*b=malloc(k),*o=malloc(k);n=fread(b,1,k,stdin);if(n==k)return 3;size_t m=refresh_plan(b,n,o,k);if(m>k)return 4;fwrite(o,1,m,stdout);return 0;}\n' | cc -O1 -w -I . -x c -o /tmp/feed - + printf '#include "gen/c/claw/refresh.c"\n#include \n#include \nint main(int c,char**v){char*e=0;long q=-1;if(c>1&&*v[1]){q=strtol(v[1],&e,10);if(*e||q<0||q>2147483647)q=-1;}size_t k=1<<22,n;uint8_t*b=malloc(k),*o=malloc(k);n=fread(b,1,k,stdin);if(n==k)return 3;size_t m=refresh_plan_queued(b,n,o,k,(int32_t)q);if(m>k)return 4;fwrite(o,1,m,stdout);return 0;}\n' | cc -O1 -w -I . -x c -o /tmp/feed - list() { gh api graphql --paginate -F owner="${R%/*}" -F name="${R#*/}" -f query='query($owner:String!,$name:String!,$endCursor:String){repository(owner:$owner,name:$name){pullRequests(states:OPEN,first:25,after:$endCursor,orderBy:{field:CREATED_AT,direction:ASC}){pageInfo{hasNextPage endCursor} nodes{number headRefOid createdAt isDraft isCrossRepository maintainerCanModify mergeable mergeStateStatus baseRefName headRepository{nameWithOwner} labels(first:50){nodes{name}}}}}}' --jq '.data.repository.pullRequests.nodes[]|[.number,.headRefOid,.createdAt,.isDraft,([.labels.nodes[].name]|join(",")),.isCrossRepository,(.headRepository.nameWithOwner//""),.maintainerCanModify,.mergeable,.mergeStateStatus,.baseRefName]|@tsv'; } # GitHub computes mergeability lazily after a push: ask once, wait, ask again. Pages of 25: at 100 the second page outran the GraphQL timeout with 207 open PRs and every push went red (#10576). list > /dev/null sleep 30 list > /tmp/prs.tsv + # Every refresh starts about 25 workflow runs: the plan is held to the queue (refresh_budget, #10588); unread is `-`, refreshes none. + q=$(gh api "repos/$R/actions/runs?status=queued&per_page=1" -q .total_count 2>/dev/null /dev/null /tmp/plan || { echo "::warning::claw/refresh could not build a plan"; exit 0; } + done < /tmp/prs.tsv | /tmp/feed "${q:--}" > /tmp/plan || { echo "::warning::claw/refresh could not build a plan"; exit 0; } while IFS=$'\t' read -r k a b; do case "$k" in R) if err=$(GH_TOKEN="$CLAW_TOKEN" gh api -X PUT "repos/$R/pulls/$a/update-branch" -f expected_head_sha="$b" 2>&1 = QUEUE_ROOM)) { + return 0; + } + uint32_t b = ((QUEUE_ROOM - q) / RUNS_PER_REFRESH); + if ((b > REFRESH_CAP)) { + return REFRESH_CAP; + } + return b; +} + +size_t refresh_plan_queued(uint8_t* raw, size_t n, uint8_t* out, size_t cap, int32_t queued) { + uint32_t b = refresh_budget(queued); + size_t w = refresh_plan_within(raw, n, out, cap, b); + if ((queued < 0)) { + return put_str(out, cap, w, QUEUE_UNREAD, QUEUE_UNREAD_LEN); + } + w = put_str(out, cap, w, QUEUE_HEAD, QUEUE_HEAD_LEN); + w = put_count(out, cap, w, ((uint32_t)(queued))); + w = put_str(out, cap, w, QUEUE_MID, QUEUE_MID_LEN); + w = put_count(out, cap, w, b); + return put_byte(out, cap, w, '\n'); +} + +size_t refresh_plan_within(uint8_t* raw, size_t n, uint8_t* out, size_t cap, uint32_t lim_in) { + uint32_t lim = lim_in; + if ((lim > REFRESH_CAP)) { + lim = REFRESH_CAP; + } uint8_t dec[512] = {0}; uint32_t num[512] = {0}; size_t cs[512] = {0}; @@ -355,7 +402,7 @@ size_t refresh_plan(uint8_t* raw, size_t n, uint8_t* out, size_t cap) { } i = 0; while ((i < cnt)) { - if (((dec[i] == D_REFRESH) && (rk[i] >= REFRESH_CAP))) { + if (((dec[i] == D_REFRESH) && (rk[i] >= lim))) { dec[i] = D_CAPPED; } i += 1; @@ -373,7 +420,7 @@ size_t refresh_plan(uint8_t* raw, size_t n, uint8_t* out, size_t cap) { st[((size_t)(D_BAD))] += extra; size_t w = 0; uint32_t r = 0; - while ((r < REFRESH_CAP)) { + while ((r < lim)) { i = 0; while ((i < cnt)) { if (((dec[i] == D_REFRESH) && (rk[i] == r))) { @@ -1284,6 +1331,106 @@ void test_the_second_digits_decide_the_order(void) { assert((out[47] == '3')); } +void test_the_budget_shrinks_as_the_queue_grows_and_is_zero_from_the_room_up(void) { + int32_t q = -1; + while ((q <= 1000)) { + uint32_t b = refresh_budget(q); + if ((q < 0)) { + assert((b == 0)); + } + if ((q == 0)) { + assert((b == 4)); + } + if (((q >= 1) && (q <= 25))) { + assert((b == 3)); + } + if (((q >= 26) && (q <= 50))) { + assert((b == 2)); + } + if (((q >= 51) && (q <= 75))) { + assert((b == 1)); + } + if ((q >= 76)) { + assert((b == 0)); + } + assert((b <= REFRESH_CAP)); + q += 1; + } +} + +void test_a_busy_queue_refreshes_nothing_and_says_how_busy(void) { + uint8_t r[4096] = {0}; + size_t n = 0; + uint32_t i = 1; + while ((i <= 5)) { + n = t_rec(&r, 4096, n, (500 + i), (i * 60), false, 0, false, false, 0, 0, 2); + i += 1; + } + uint8_t out[4096] = {0}; + size_t d = refresh_plan_queued(&r, n, &out, 4096, 886); + assert((refresh_fits(d, 4096) == true)); + assert((t_lines(&out, d, 'R') == 0)); + assert((buf_has(&out, d, " 0 to update, ", 14) == true)); + assert((buf_has(&out, d, " 5 over the cap, ", 17) == true)); + assert((buf_has(&out, d, "M\tclaw-refresh: the Actions queue holds 886 runs, so this merge refreshes at most 0\n", 84) == true)); +} + +void test_a_quiet_queue_refreshes_the_oldest_within_its_budget(void) { + uint8_t r[8192] = {0}; + size_t n = 0; + uint32_t i = 10; + while ((i > 0)) { + n = t_rec(&r, 8192, n, (600 + i), (i * 60), false, 0, false, false, 0, 0, 2); + i -= 1; + } + uint8_t out[8192] = {0}; + size_t d = refresh_plan_queued(&r, n, &out, 8192, 30); + assert((t_lines(&out, d, 'R') == 2)); + assert((buf_has(&out, d, "R\t601\t", 6) == true)); + assert((buf_has(&out, d, "R\t602\t", 6) == true)); + assert((buf_has(&out, d, "R\t603\t", 6) == false)); + assert((buf_has(&out, d, " 8 over the cap, ", 17) == true)); + assert((buf_has(&out, d, "holds 30 runs, so this merge refreshes at most 2\n", 49) == true)); +} + +void test_an_unread_queue_refreshes_nothing_and_says_it_could_not_read(void) { + uint8_t r[512] = {0}; + size_t n = t_rec(&r, 512, 0, 701, 100, false, 0, false, false, 0, 0, 2); + uint8_t out[4096] = {0}; + size_t d = refresh_plan_queued(&r, n, &out, 4096, -1); + assert((t_lines(&out, d, 'R') == 0)); + assert((buf_has(&out, d, QUEUE_UNREAD, QUEUE_UNREAD_LEN) == true)); + assert((buf_has(&out, d, "holds", 5) == false)); +} + +void test_the_plan_within_a_limit_never_passes_the_cap(void) { + uint8_t r[16384] = {0}; + size_t n = 0; + uint32_t i = 1; + while ((i <= 35)) { + n = t_rec(&r, 16384, n, (800 + i), (i * 60), false, 0, false, false, 0, 0, 2); + i += 1; + } + uint8_t out[16384] = {0}; + size_t d = refresh_plan_within(&r, n, &out, 16384, 1000); + assert((t_lines(&out, d, 'R') == 30)); +} + +void test_the_queue_texts_are_pinned_by_their_lengths(void) { + uint8_t b[128] = {0}; + size_t w = put_str(&b, 128, 0, QUEUE_HEAD, QUEUE_HEAD_LEN); + assert((w == QUEUE_HEAD_LEN)); + assert((b[(w - 1)] == ' ')); + assert((b[(w - 2)] == 's')); + w = put_str(&b, 128, 0, QUEUE_MID, QUEUE_MID_LEN); + assert((b[(w - 1)] == ' ')); + assert((b[(w - 2)] == 't')); + w = put_str(&b, 128, 0, QUEUE_UNREAD, QUEUE_UNREAD_LEN); + assert((b[(w - 1)] == '\n')); + assert((b[(w - 2)] == 'e')); + assert((b[(w - 3)] == 'n')); +} + /* ------------------------------------------------------- Test runner (compile with -DT27_TEST_MAIN to execute) @@ -1336,7 +1483,13 @@ int main(void) { test_the_513th_record_is_counted_as_unreadable_and_not_stored(); test_the_hour_digits_decide_the_order(); test_the_second_digits_decide_the_order(); - printf("All %d tests passed.\n", 44); + test_the_budget_shrinks_as_the_queue_grows_and_is_zero_from_the_room_up(); + test_a_busy_queue_refreshes_nothing_and_says_how_busy(); + test_a_quiet_queue_refreshes_the_oldest_within_its_budget(); + test_an_unread_queue_refreshes_nothing_and_says_it_could_not_read(); + test_the_plan_within_a_limit_never_passes_the_cap(); + test_the_queue_texts_are_pinned_by_their_lengths(); + printf("All %d tests passed.\n", 50); return 0; } #endif /* T27_TEST_MAIN */ diff --git a/specs/claw/refresh.t27 b/specs/claw/refresh.t27 index c69ad2b3f4..8216df0f48 100644 --- a/specs/claw/refresh.t27 +++ b/specs/claw/refresh.t27 @@ -29,6 +29,12 @@ ; refresh behind and conflict-free ; Then at most REFRESH_CAP of the refresh ones are kept, oldest createdAt first (ties: lower number), ; the rest are counted as over the cap and wait for the next merge. +; The queue budget (refresh_plan_queued, which the job calls; #10588): every update-branch moves a +; head, and a moved head starts about RUNS_PER_REFRESH workflow runs. On 2026-10-18 the repository +; had 886 runs queued against 17 running, and 30 refreshes a merge would have added 750 more. So the +; job also passes the number of queued runs: at QUEUE_ROOM or more nothing is refreshed, below it at +; most (QUEUE_ROOM - queued) / RUNS_PER_REFRESH, and an unread count refreshes nothing. The ruleset +; does not require a PR to be up to date, so a refresh that waits costs freshness, never a merge. ; -- ; Fail closed: a record that is not eleven fields, a number that is not a positive integer, a sha that ; is not 40 lowercase hex digits, a createdAt that is not YYYY-MM-DDTHH:MM:SSZ, a flag that is not @@ -47,6 +53,16 @@ use claw::text; pub const MAX_PRS : usize = 512; /// FR-002: the most pull requests one run refreshes, so one merge cannot flood the Actions queue. pub const REFRESH_CAP : u32 = 30; +/// Queued workflow runs at which a merge refreshes nothing. +pub const QUEUE_ROOM : u32 = 100; +/// Workflow runs one moved pull request head starts (23 to 27 on 2026-10-18). +pub const RUNS_PER_REFRESH : u32 = 25; +pub const QUEUE_HEAD : str = "M\tclaw-refresh: the Actions queue holds "; +pub const QUEUE_HEAD_LEN : usize = 40; +pub const QUEUE_MID : str = " runs, so this merge refreshes at most "; +pub const QUEUE_MID_LEN : usize = 39; +pub const QUEUE_UNREAD : str = "M\tclaw-refresh: the Actions queue could not be read, so this merge refreshes none\n"; +pub const QUEUE_UNREAD_LEN : usize = 82; /// Fields of one record. pub const NFIELDS : usize = 11; @@ -252,6 +268,37 @@ fn rf_put_sha(dst : []u8, cap : usize, w : usize, raw : []const u8, s : usize) - /// Returns the bytes it needs; when that exceeds cap the buffer holds a clipped answer that must not /// be acted on. pub fn refresh_plan(raw : []const u8, n : usize, out : []u8, cap : usize) -> usize { + return refresh_plan_within(raw, n, out, cap, REFRESH_CAP); +} + +/// How many pull requests one merge may refresh with `queued` workflow runs waiting (-1: unread). +pub fn refresh_budget(queued : i32) -> u32 { + if (queued < 0) { return 0; } + const q : u32 = queued as u32; + if (q >= QUEUE_ROOM) { return 0; } + const b : u32 = (QUEUE_ROOM - q) / RUNS_PER_REFRESH; + if (b > REFRESH_CAP) { return REFRESH_CAP; } + return b; +} + +/// What the job calls: refresh_plan held to refresh_budget(queued), then one line that says why. +pub fn refresh_plan_queued(raw : []const u8, n : usize, out : []u8, cap : usize, queued : i32) -> usize { + const b : u32 = refresh_budget(queued); + var w : usize = refresh_plan_within(raw, n, out, cap, b); + if (queued < 0) { + return put_str(out, cap, w, QUEUE_UNREAD, QUEUE_UNREAD_LEN); + } + w = put_str(out, cap, w, QUEUE_HEAD, QUEUE_HEAD_LEN); + w = put_count(out, cap, w, queued as u32); + w = put_str(out, cap, w, QUEUE_MID, QUEUE_MID_LEN); + w = put_count(out, cap, w, b); + return put_byte(out, cap, w, '\n'); +} + +/// refresh_plan with the cap lowered to lim (never raised past REFRESH_CAP). +pub fn refresh_plan_within(raw : []const u8, n : usize, out : []u8, cap : usize, lim_in : u32) -> usize { + var lim : u32 = lim_in; + if (lim > REFRESH_CAP) { lim = REFRESH_CAP; } var dec : [512]u8 = undefined; var num : [512]u32 = undefined; var cs : [512]usize = undefined; @@ -311,7 +358,7 @@ pub fn refresh_plan(raw : []const u8, n : usize, out : []u8, cap : usize) -> usi } i = 0; while (i < cnt) { - if (dec[i] == D_REFRESH and rk[i] >= REFRESH_CAP) { dec[i] = D_CAPPED; } + if (dec[i] == D_REFRESH and rk[i] >= lim) { dec[i] = D_CAPPED; } i += 1; } var c : usize = 0; @@ -327,7 +374,7 @@ pub fn refresh_plan(raw : []const u8, n : usize, out : []u8, cap : usize) -> usi st[D_BAD as usize] += extra; var w : usize = 0; var r : u32 = 0; - while (r < REFRESH_CAP) { + while (r < lim) { i = 0; while (i < cnt) { if (dec[i] == D_REFRESH and rk[i] == r) { @@ -1018,3 +1065,93 @@ test the_second_digits_decide_the_order { assert out[2] == '9'; assert out[47] == '3'; } + +// ---- tests: the queue budget (#10588) ---- + +test the_budget_shrinks_as_the_queue_grows_and_is_zero_from_the_room_up { + var q : i32 = -1; + while (q <= 1000) { + const b : u32 = refresh_budget(q); + if (q < 0) { assert b == 0; } + if (q == 0) { assert b == 4; } + if (q >= 1 and q <= 25) { assert b == 3; } + if (q >= 26 and q <= 50) { assert b == 2; } + if (q >= 51 and q <= 75) { assert b == 1; } + if (q >= 76) { assert b == 0; } + assert b <= REFRESH_CAP; + q += 1; + } +} + +test a_busy_queue_refreshes_nothing_and_says_how_busy { + var r : [4096]u8 = undefined; + var n : usize = 0; + var i : u32 = 1; + while (i <= 5) { + n = t_rec(&r, 4096, n, 500 + i, i * 60, false, 0, false, false, 0, 0, 2); + i += 1; + } + var out : [4096]u8 = undefined; + const d : usize = refresh_plan_queued(&r, n, &out, 4096, 886); + assert refresh_fits(d, 4096) == true; + assert t_lines(&out, d, 'R') == 0; + assert buf_has(&out, d, " 0 to update, ", 14) == true; + assert buf_has(&out, d, " 5 over the cap, ", 17) == true; + assert buf_has(&out, d, "M\tclaw-refresh: the Actions queue holds 886 runs, so this merge refreshes at most 0\n", 84) == true; +} + +test a_quiet_queue_refreshes_the_oldest_within_its_budget { + var r : [8192]u8 = undefined; + var n : usize = 0; + var i : u32 = 10; + while (i > 0) { + n = t_rec(&r, 8192, n, 600 + i, i * 60, false, 0, false, false, 0, 0, 2); + i -= 1; + } + var out : [8192]u8 = undefined; + const d : usize = refresh_plan_queued(&r, n, &out, 8192, 30); + assert t_lines(&out, d, 'R') == 2; + assert buf_has(&out, d, "R\t601\t", 6) == true; + assert buf_has(&out, d, "R\t602\t", 6) == true; + assert buf_has(&out, d, "R\t603\t", 6) == false; + assert buf_has(&out, d, " 8 over the cap, ", 17) == true; + assert buf_has(&out, d, "holds 30 runs, so this merge refreshes at most 2\n", 49) == true; +} + +test an_unread_queue_refreshes_nothing_and_says_it_could_not_read { + var r : [512]u8 = undefined; + const n : usize = t_rec(&r, 512, 0, 701, 100, false, 0, false, false, 0, 0, 2); + var out : [4096]u8 = undefined; + const d : usize = refresh_plan_queued(&r, n, &out, 4096, -1); + assert t_lines(&out, d, 'R') == 0; + assert buf_has(&out, d, QUEUE_UNREAD, QUEUE_UNREAD_LEN) == true; + assert buf_has(&out, d, "holds", 5) == false; +} + +test the_plan_within_a_limit_never_passes_the_cap { + var r : [16384]u8 = undefined; + var n : usize = 0; + var i : u32 = 1; + while (i <= 35) { + n = t_rec(&r, 16384, n, 800 + i, i * 60, false, 0, false, false, 0, 0, 2); + i += 1; + } + var out : [16384]u8 = undefined; + const d : usize = refresh_plan_within(&r, n, &out, 16384, 1000); + assert t_lines(&out, d, 'R') == 30; +} + +test the_queue_texts_are_pinned_by_their_lengths { + var b : [128]u8 = undefined; + var w : usize = put_str(&b, 128, 0, QUEUE_HEAD, QUEUE_HEAD_LEN); + assert w == QUEUE_HEAD_LEN; + assert b[w - 1] == ' '; + assert b[w - 2] == 's'; + w = put_str(&b, 128, 0, QUEUE_MID, QUEUE_MID_LEN); + assert b[w - 1] == ' '; + assert b[w - 2] == 't'; + w = put_str(&b, 128, 0, QUEUE_UNREAD, QUEUE_UNREAD_LEN); + assert b[w - 1] == '\n'; + assert b[w - 2] == 'e'; + assert b[w - 3] == 'n'; +}