Repository navigation
epic(actions): make Noema, Strix, and OpenCode concurrency exact-PR and current-head safe #1800
Copy link
Copy link
Open
Labels
enhancementNew feature or requestNew feature or requestpriority: mediumNormal-priority or P2 workNormal-priority or P2 work
Description
목표
Noema·Strix·OpenCode의 model-backed PR 검토가 동일한 공통 계약을 따르게 합니다.
{workflow명}-{repository}-{PR번호}
- 서로 다른 저장소와 PR은 독립적으로 실행
- 같은 PR의 이전 HEAD만 live-head 재검증 후 취소
- delayed stale event가 current running/pending 실행을 먼저 취소하지 못함
- native PR trigger와 repository_dispatch retry가 같은 PR queue를 공유
- release·deploy·migration은 이 cancellation owner의 대상이 아님
현재 RED — protected main@232107a0b6235efaa4a221a41443c436eac3dd00
Noema
- protected main은 workflow-level PR group과 event-conditional
cancel-in-progress로 delayed stale event가 current run을 선취소할 수 있음 - #1797이 job-level group + 별도 cleanup으로 좁게 수리 중
- #1797에는 아직
queue: max가 없어 current pending이 stale pending에 밀릴 수 있음 - #1797의 sibling
repository_dispatchcleanup은 중앙${{ github.token }}으로 target Actions API를 호출해 권한이 없음; #1799가 canonical successor
Strix
strixjob group은format('{0}-{1}-{2}', github.event_name, repository, PR)이므로 같은 PR의pull_request_target과repository_dispatch가 서로 다른 group- 따라서 merge scheduler retry와 native scan이 같은 PR에서 동시에 provider를 소비할 수 있음
cancel-in-progress: false지만queue: max가 없고, 현재 계약 테스트가 오히려queue: max부재를 강제- 별도 cleanup은 native synchronize/closed만 다루므로 dispatch queue의 stale run은 같은 owner 계약 밖
OpenCode
- unprivileged bootstrap
opencode-review-target은 job-level repository+PR group과cancel-in-progress: false이나queue: max가 없음 - privileged
opencode-review-dispatch.yml은 workflow-level repository+PR group에cancel-in-progress: true - delayed stale dispatch는 metadata/live-head validation job이 실행되기 전에 current dispatch 전체를 취소할 수 있음
- bootstrap과 privileged dispatch가 서로 다른 workflow/group이므로 동일 PR의 실제 OpenCode 작업 수가 하나로 coalesce되지 않음
불변 계약
- PR model job group에는 workflow identity, target repository, PR number만 포함하며 head SHA와 event class는 넣지 않습니다.
- long-running model job은 job-level
cancel-in-progress: false와queue: max를 사용합니다. - stale-run cleanup은 model group 밖의 짧은 별도 job 또는 target-repository coalescer가 담당합니다.
- cleanup은 target-scoped credential로 exact live PR state/head를 취소 직전에 다시 읽습니다.
- current head·다른 PR·다른 repository·non-review workflow는 절대 취소하지 않습니다.
- repository_dispatch payload의 target/head는 권위가 아니며 target API로 재검증합니다.
- 같은 PR의 native/dispatch event class는 같은 model queue를 사용합니다.
- API rate limit·network·malformed response에서는 wrong cancellation보다 fail-closed를 우선하고 typed telemetry를 남깁니다.
- fixed sleep, blind retry, head-SHA group, repository-wide model group은 금지합니다.
- model-backed workflow는
orchestrator/free와 gateway token만 사용합니다.
PR Stack과 진척률
- Noema native current-head queue — fix(noema-review): close item13 stale-head native-cancellation race #1797 (
queue: maxfinding 미해결) - Noema sibling dispatch credential — fix(noema-review): authorize sibling repository_dispatch cleanup with a target-scoped token #1799
- Strix native/dispatch 동일 group + pending 보존 — successor PR 필요
- OpenCode privileged dispatch stale-cancel race — successor PR 필요
- OpenCode bootstrap/dispatch 실제 model work 단일 queue 계약 — successor PR 필요
- 공통 executable fixture: running current, pending current, delayed stale, newer current, closed PR
- exact-head hosted Checks와 독립 리뷰
- unchanged consumer canary에서 다른 PR 오취소 0건
현재 완료율: 0/8. 분석·이슈 작성은 완료로 계산하지 않습니다.
측정
- 동일 PR push burst당 materialized model jobs
- current-head evidence 누락률
- previous-head 잔존 시간
- 다른 PR 오취소 수
- native/dispatch 중복 실행 수
- provider 호출 수와 rate-limit 실패
- startup_failure 및
runner_id=0분모
협업
각 successor는 독립 세션이 맡아도 되지만 같은 파일에 동시 writer가 생기지 않도록 Noema → Strix → OpenCode 순으로 owner stack을 명시하고, 선행 PR head/base를 이 이슈에 기록하십시오. 대형 #1661의 유효 delta는 이 stack과 별도 successor가 완전 승계한 뒤에만 종결할 수 있습니다.
기다리는 동안 다른 로컬 세션에게 지속적으로 지시 받아 도우세요. 계속 묻고 계속 피드백하고 계속 지원하세요. 더 주도적이고 더 적극적으로요.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or requestpriority: mediumNormal-priority or P2 workNormal-priority or P2 work
Activity
seonghobae commentedon Sep 6, 2026
Push-triggered Strix runs cannot coalesce, and the obvious fix is the one this repo already tried and rejected
Measured while tracing what actually occupies runner slots. Posting it here rather than opening a PR,
because the change it points to would reverse an explicit owner decision recorded on 2026-09-03.
What is occupying the slots
At 14:10Z the
.githubqueue held 203 queued runs against 11 in-flight, and every in-flight runwas a Strix scan. Ten of them:
github.run_id(push to main)github.run_id(push to main)github.run_id(push to main)github.run_id(push to main)All four
run_idruns areevent=push,head_branch=main.strix.ymltriggers onpush: branches: [main, develop, master], and on a push neithergithub.event.pull_request.numbernor
github.event.client_payload.pr_numberexists, so the group key falls through togithub.run_id— unique per run, so no push-triggered scan can ever supersede another.Rate and duration over the last 100 push-triggered Strix runs (2026-09-03T13:13Z → 09-06T12:03Z,
70.8 h):
success/failure: median 5.80 h, max 20.90 hceiling is itself throttling them)
By contrast PR-triggered Strix coalesces normally: of 20 recent completions, 15 are
cancelledwith amedian lifetime of 0.20 h. The asymmetry is entirely in the group key.
Why the obvious fix is wrong
Changing the fallback from
github.run_idtogithub.refwould make consecutive main pushes share agroup. That is repository-wide scoping for the push lane, and
tests/test_required_workflow_queue_contract.py::test_strix_serializes_provider_evidence_per_repository_and_prrecords that this repository already ran that experiment:
and then, on explicit owner authorization (2026-09-03), it was restored to PR-scoped with
"non-PR events use a unique run id" as part of that decision, pinned by
assert "github.run_id" in concurrency_contract.An independent measurement today shows the same failure mode in the adjacent lane:
scheduled-security-scan.ymlis a main-push full-tree CodeQL scan with a shared group andcancel-in-progress, and over 2026-09-01→09-05 it produced 228 cancelled runs and zero completions— every scan killed by the next merge, because main advances faster than a scan finishes. Coverage
only resumed on 09-05 when the queue drained, not because anything was fixed. Giving push-triggered
Strix a shared group would reproduce exactly that, on a scanner whose median run is 5.8 h against a
main that advances every ~12 minutes.
So both settings are known-bad in different ways, and neither is a coding error to correct:
What actually needs deciding, and by whom
The lever is not the concurrency key. It is whether a full-tree Strix scan should start on every
push to a protected branch at all.
strix.yml's own header notes the weekly full-tree schedule"backstops every path", and a full-tree scan is retroactive by construction — a later scan covers
commits an earlier one missed. That makes consecutive per-push scans largely redundant while each
holds a slot for hours.
Options, none of which an agent should pick unilaterally:
pushfromstrix.yml's triggers, relying on the PR scan plus the weekly full-treeschedule. Largest capacity gain; reduces per-commit coverage on protected branches.
cancel-in-progress: falseso pushes serialize(one running, one pending, older pendings dropped) instead of running in parallel. Bounds occupancy
at 1 without the cancel-storm starvation of option 3's
truevariant, at the cost of skippingintermediate commits. This is the
${{ github.event_name == … }}conditional pattern already usedin
pr-review-merge-scheduler.yml,cloudflare-dns.ymlandrepository-metadata-reconcile.yml.I would recommend option 3 — it is the only one that bounds occupancy without either reversing the
2026-09-03 PR-scoping decision or reducing which commits get scanned to zero — but it still changes
push-lane semantics the owner set deliberately, so it wants an explicit ruling first. Happy to
implement whichever is chosen, with contract tests and the usual controls.
Not claimed
The 66 of 100 push-triggered Strix runs that ended
cancelledwere not cancelled by concurrency(their keys are unique). I did not determine what cancelled them — queue sweeps and explicit
cancel jobs both exist in this repository — so the practical occupancy is lower than the unmitigated
8.2 estimate. That does not change the group-key analysis, but it does mean "nothing supersedes them"
is a statement about the concurrency mechanism, not about their observed lifetimes.
seonghobae commentedon Sep 6, 2026
Correction: the starvation precedent argues against option 3's cancelling variant, not against option 3
My comment above said "Giving push-triggered Strix a shared group would reproduce exactly that". That
is too broad, and the distinction it flattens is the one that decides between the options. All figures
below re-measured directly.
The two cases are not the same shape
scheduled-security-scan.ymlreally is configured to cancel across main pushes —group: scheduled-security-scan-${{ github.repository }}-${{ github.ref }}withcancel-in-progress: true, and every main push lands onrefs/heads/main. But its scans are fast:The distribution is bimodal: 24 of 30 finish inside 10 minutes, comfortably under the 11.7-minute
merge interval, and a tail of six sits at 78–165 minutes. So this workflow does not starve because
scanning is expensive — it starves only when queue saturation inflates its lifetime past the merge
interval, which is why coverage resumed on its own on 09-05 when the queue drained. Nothing was
fixed.
Strix's push lane is the opposite: median 5.80 h of genuine work against the same 11.7-minute
merge interval.
scheduled-security-scanWhat that changes
The precedent is evidence against giving Strix's push lane a shared group with
cancel-in-progress: true— for a 5.8-hour job on a branch that advances every 12 minutes, thecompletion probability under a cancelling group is effectively zero, permanently rather than during a
saturation episode. It is not evidence against option 3, which specifies
false. Without cancels,intrinsic slowness stops converting into lost work.
Option 3's real cost is not loss but scarcity: bounded at one running plus one pending, most main
commits get no push scan, and the ones that do arrive roughly every 5.8 hours. The justification for
accepting that is the retroactive-coverage property of a full-tree scanner — a later scan covers what
earlier ones missed.
Correction to my own option list: I attached the two exceptions to that property only to option 2.
They apply to option 3 equally, and should be read as its cost too:
introduced it.
A monitoring indicator worth adding regardless of the choice
scheduled-security-scan.ymlis fine as configured as long as the queue is healthy, and it failssilently when it is not: a starved run's status is
cancelled, which raises no alert. The leadingindicator is the ratio of its successful-run lifetime median to the main merge-interval median —
3.6 / 11.7 = 0.31 today. As that ratio approaches 1, this workflow is about to stop producing
coverage without anything reporting a failure. It reached that state for four days (09-01 → 09-05,
228 cancelled, 0 completed) and nobody was told.
Still not claimed
Of the 100 push-triggered Strix runs, 66 ended
cancelledand not by concurrency (their keys areunique), and I still have not identified what cancelled them. A usable discriminator has been
suggested — a run killed by
cancel-in-progresstypically hasrun_attempt=1with no job everstarted— and that decomposition is being done separately. Until it is, the real occupancy is belowthe 8.2 estimate and the option comparison above is unaffected either way.
seonghobae commentedon Sep 6, 2026
Option 3 is already running in this repository, on a push lane, and it behaves as hoped
Two measurements to add. The first replaces my earlier hand-waving about what cancelled Strix runs
actually cost; the second is an in-repo empirical test of the option I recommended, which I had only
argued from first principles.
1. What the cancelled push-Strix runs cost — now agreed by two independent computations
All 66
cancelledruns among the newest 100event=pushstrix.ymlruns (created2026-09-03T13:13:59Z → 09-06T03:01:19Z), occupancy computed as the sum of each job's
started_at → completed_at:strixjob itself ran > 1 minA second session computed the same set independently and matched every figure. The only divergence
was a median of 16.1 vs 13.3 min, which is the upper-median versus mean-of-middle-two convention on an
even n, not a data difference. Their first pass had reported "median 0.1 min, 51 of 66 free"; the
cause was a filter requiring
started_at > created_at, which silently drops any job whose queue waitwas under a second — including jobs that then ran for 65 minutes. Worth recording as a rule:
created == startedis only valid for spotting a cancelled queued job; it must never be used as aproxy for "no runner was allocated" when measuring occupancy. Occupancy needs
startedandcompletedalone.There is also a strong drift inside the window, so a small recent sample and a small old sample would
disagree without either being wrong:
This strengthens the earlier point about sweeps: killing a run that has been executing for hours
throws that time away, while killing one that never got a runner costs nothing. A sweep with an
occupancy threshold recovers the same slots for far less waste.
2.
scorecard-analysis.ymlis option 3, live, on a push laneI recommended option 3 (shared group,
cancel-in-progress: false) from reasoning. It turns out thisrepository already runs exactly that shape:
Behaviour over its 60 most recent push runs (09-04T10:47 → 09-06T12:03, 49.3 h, 1.22 created/h):
The decisive detail: every one of 12 sampled cancelled runs has
jobs = 0(12 of 12 queriedsuccessfully, no API failures). They were dropped while still pending, before any job was created, so
they consumed no runner time at all. A successful run by contrast shows its
Scorecard analysisjobstarted and completed normally.
That answers the objection I was most worried about. A concern raised earlier — that a shared group
starves a slow job — was measured on
scheduled-security-scan.yml, whose scans take a median of3.6 min. Scorecard is not that: 88.4 min median, more than twenty times slower, on the same
branch advancing every ~11.7 min, and it still completes 18 times in 49 hours without piling up. The
difference is
cancel-in-progress: false. With cancelling, a slow job is killed mid-flight and itswork is lost; without it, the running instance finishes and only pending successors are discarded.
What this implies for the Strix push lane
Option 3 on
strix.yml's push lane should give: occupancy bounded at one running scan instead of the~8 an uncoalesced lane predicts; superseded pushes costing zero because they never reach job creation;
and no mid-flight kills, so no 5.8-hour scan is ever thrown away.
Its cost is coverage, and it should be stated honestly. Scorecard scans 18 of 60 pushes — 30%. At
Strix's 5.8 h median against 1.41 pushes/h, serialising would scan roughly one push in eight. The
two exceptions to full-tree retroactive coverage apply to that gap: code introduced and removed
between two scans is never scanned, and findings lose their pull-request attribution.
So the trade is: ~8 concurrent long scans and every push covered, versus ~1 concurrent and roughly an
eighth of pushes covered, with the same per-commit retroactive property the weekly full-tree schedule
already relies on. That is a coverage-versus-capacity decision, still the owner's, but option 3 is no
longer a proposal without precedent — it is the configuration a sibling security workflow in this repo
has been running successfully on the same branch cadence.
seonghobae commentedon Sep 6, 2026
The waste is not spread across cancellations — two minutes account for 60% of it
Follow-up measurement, and it changes which item is actionable. Same 66 cancelled
event=pushstrix.ymlruns as above (118.4 h total occupancy), now grouped by the minute each was cancelled(
updated_at), with the occupancy each group destroyed. All 66 fetched successfully (ok=66, FAILED=0):Two minutes out of three days destroyed 71.1 h — 60% of all the occupancy lost to cancellation.
Nothing cancels eight runs inside one minute except a batch sweep; concurrency cancels at most one run
per newly created sibling, and these runs cannot supersede each other at all (unique
github.run_idkeys).
That reframes the earlier "91% of the waste sits in 22 long runs". True, but not actionable on its
own — those 22 are long because Strix is long. The actionable fact is that a handful of sweep events
killed work that was already hours in, and the same sweeps were harmless where they hit runs that
had never been allocated a runner (see the 0.0–0.3 h clusters, which are all-
jobs=0rows).The sweeps are cross-workflow
A parallel session found that the 19:51 sweep also killed a
scorecard-analysis.ymlrun —33896184887, created 16:37:33Z, itsScorecard analysisjob running until 19:51:11Z, 193.6 mindestroyed. I verified that run directly. Adding it, the single 19:51 sweep destroyed 40.3 h across
two workflows.
Method note for anyone reproducing this: querying
actions/workflows/strix.yml/runs?status=cancelled&per_page=100does not reach back to 09-04 —the newest 100 cancelled Strix runs are dominated by pull-request events. Filter
event=pushfirst,or the sweep clusters are invisible.
Why this is independent of the option-3 decision
scorecard-analysis.ymlruns option 3 (sharedgithub.refgroup,cancel-in-progress: false), andof its 42 recent cancelled push runs 41 have
jobs = 0— dropped while pending, costing nothing.That is option 3 working exactly as intended on the concurrency axis.
The 42nd is the one the sweep killed at 19:51, after 193.6 minutes of real work. So:
They are not alternatives and one does not subsume the other. Whichever way the push-lane question is
decided, a sweep that skips runs above an occupancy threshold would have preserved 71.1 h in this
window alone, at no cost to the slots it was trying to reclaim: every run in the near-zero clusters
had
jobs = 0and could still be cancelled freely.Convention note
Two sessions reported medians differing three times today (16.1/13.3, 88.4/85.6) purely because n was
even and one used the upper median while the other used the mean of the middle pair. Neither is wrong.
Reporting both middle values on an even n removes the round trip; figures above are sums and counts,
which are convention-free.
seonghobae commentedon Sep 6, 2026
The occupancy threshold has no code to live in — no committed sweep can produce these cancellations
I went looking for where to add the threshold. It is not there, and the negative result is the useful
part.
Every bulk-cancel path in this repository is scoped so that it cannot touch a push-triggered run:
pushrun?scripts/ci/current_head_run_coalescer.py:27,106PR_EVENTS = {"pull_request","pull_request_target"}, returns early otherwisescripts/ci/pr_review_fix_scheduler.py:~440-464event=repository_dispatch, name must matchAUTOFIX_RUN_NAME_REfor that exact repo and PR number, head must differscripts/ci/pr_review_merge_scheduler_core.py:3532,3559,3648force_cancel_workflow_runs(repo, [run_id])— single-element lists.github/workflows/strix.yml:217if: github.event_name == 'pull_request_target' && ….github/workflows/noema-review.yml:~108-140PR_ACTIONbeingclosed/converted_to_draft, and re-verifies the live PR first.github/workflows/opencode-review.yml:515There is also no
org-queue-sweep-style workflow present by that or any similar name.So the two sweeps that destroyed 71.1 h — eight Strix push runs at 2026-09-04T19:51 and three more at
04:43, plus a
scorecard-analysis.ymlrun at the same 19:51 minute — were not produced by committedautomation. They came from ad-hoc activity: sessions or manual API calls.
What that means for the recommendation
The occupancy threshold cannot be implemented as a patch, because there is no committed sweeper to
patch. It is a working rule for whoever runs a sweep, and it is small enough to state exactly:
Applied to this window, that single check would have preserved 71.1 h of runner time — 60% of all
occupancy lost to cancellation — while still reclaiming every queue slot the sweeps were aiming at,
because the free rows and the expensive rows are disjoint.
It also composes with, rather than replaces, whichever push-lane option is chosen: option 3 removes
the concurrency-axis waste (superseded pushes never reach job creation), and this rule removes the
sweep-axis waste. Neither covers the other.
Caveat
This is an audit of the repository's own workflows and
scripts/ci/. I cannot see tooling that livesoutside the repository, and the Actions API exposes no actor for a cancellation, so "not produced by
committed automation" is a statement about what is in this tree — not an identification of what did
produce them.
seonghobae commentedon Sep 6, 2026
Addendum: the exclusion is now empirical, not just structural
My comment above argued from source that no committed path can cancel a push-triggered run. A parallel
session tested the most plausible candidate against live data and closed it properly, which is worth
recording because a source argument can always be missing a path.
The candidate was
pr_review_merge_scheduler_core.py:3389 force_cancel_workflow_runs, which fans itsforce-cancelcalls out through a thread pool — the exact shape that produces eight cancellationsinside one minute. It reaches runs through
cancel_stale_pr_runs, whose gate isworkflow_run_mentions_pr. That predicate reads GitHub'spull_requestsassociation array, not thedisplay title:
Strix run names do embed a PR number (
Strix Security Scan …/.github#1757@sha), so a title-matchingimplementation would have caught push runs. This one cannot. Checked against two of the eight runs
killed at 19:51:
pull_requestsis empty and the name carries#eventrather than a number, so the predicate is falsefor them and this path is excluded on evidence rather than on reading. Two further paths I had folded
together are separately excluded by scope:
cancel_stale_opencode_runstakesactive_opencode_run_ids(repo, workflow, pr)and_cancel_revalidated_review_run_refstakes a PRargument; both are review-workflow-bound.
Checking whether a given session ran one
Since the sweeps come from ad-hoc activity rather than committed code, the population of possible
sources is the set of sessions and operators with API access. That is checkable per session, cheaply,
and worth doing before anyone reasons about who to ask:
The 11-digit constraint matters. A plain search for
force-cancelin my own transcript returns 36hits, every one of them source text I had read — contract assertions of the form
assert "/force-cancel" in cleanup_job— and none a command. Constrained to real run ids the count is0, so this session did not perform these cancellations. That is a narrowing, not an attribution:
the Actions API exposes no actor for a cancellation, and I can only audit my own transcript.
seonghobae commentedon Sep 6, 2026
Correction:
jobs = 0is not the discriminator, and "two minutes" should read "two campaigns"Two things I published above are wrong. A parallel session widened the 04:43 cluster and the result
breaks the rule as I stated it.
The rule I gave would block safe cancellations and still not identify the expensive ones
Regrouping the cancellations by campaign (10-minute gaps) rather than by exact minute, the 04:43
cluster is really six runs between 04:34 and 04:58. Laid out with age, occupancy and job count:
I wrote: "a run with
jobs = 0, or whose jobs all havestarted_at == null, can be cancelledfreely." Not one of these six qualifies — every one has jobs, and the cheap ones have already
started some. My condition would have exempted all six, blocking the three genuinely free
cancellations (19.6 minutes between them) and reclaiming nothing.
It fails in the other direction too: the free runs have
jobs = 4and the expensive onesjobs = 2.Job count is anti-correlated with cost here.
jobs = 0is sufficient but not necessary. It guarantees a run is free; it does not find the freeones. Corrected rule:
On this campaign that cancels three runs costing 19.6 minutes and exempts three holding 34.0 hours —
same slots reclaimed, 34 hours preserved.
Age is not a proxy either, and the observed order was the worst one
I had privately suspected an "oldest first" sweep and left it out of the record for lack of evidence.
Leaving it out was right: it is refuted. The three killed at 04:43 were the younger three; the three
oldest died 12–15 minutes later. And the direction is inverted from what intuition suggests — the
oldest runs had been waiting, not running (16.1 / 3.4 / 0.1 minutes of occupancy against 771–837
minutes of age), while the younger ones had been executing essentially their whole lives. Had the
sweep actually gone oldest-first it would have discarded 19.6 minutes instead of 34 hours.
So neither age, nor job count, nor time-since-creation separates cheap from expensive. Only occupancy
does.
"Two minutes" was the wrong unit
My earlier framing — "two minutes out of three days account for 60%" — used the observation unit as if
it were the event unit. Grouped into campaigns the same data reads 71.7 h across two campaigns =
61% of the 118.4 h. The headline number survives; the sentence describing it was inaccurate.
seonghobae commentedon Sep 6, 2026
The occupancy rule as I wrote it would have approved the wrong cancellations it was meant to prevent
A session supplied the three runs it had wrongly cancelled earlier today and then recovered by
re-running. Their occupancy, measured the way my rule prescribes:
Every threshold I discussed — 5 minutes through 120 — approves all three. The rule I published
would have blessed precisely the mistake it exists to prevent.
That is not a bad threshold. It is a category error on my part: occupancy answers "what does
cancelling destroy?", which is a cost, not a permission. Those three cancellations destroyed almost
no runner time and were still wrong, because the runs were not stale — all three pull requests still
had the same head.
The rule needs two predicates, ANDed, and the order matters
The three runs above pass on cost and fail on subject. Evaluate subject first: it is a single
cheap read per candidate and most fall out there, so the per-job API calls occupancy needs are only
paid on the survivors. Measured on a real audit today, 138 of 161 candidates were eliminated by the
subject predicate, leaving 23 to price.
What the occupancy analysis is still good for
Unchanged: the bimodal distribution, the 117-minute cliff, the insensitivity of the result to any
threshold between 5 and 120 minutes, and the finding that two campaigns destroyed 71.7 h — 61% of
118.4 h. Those remain the right basis for estimating waste, and for the "do not kill work already
hours in" half of the rule. None of it licenses a cancellation.
Two costs the occupancy metric cannot see
Also worth recording, because they are what the wrong cancellations actually destroyed:
45 minutes each given back to save 3 seconds. Nearly free in runner-seconds, expensive in wall
clock.
dispatched, sothe pull request cannot receive a fresh one. That is a mechanism read from
plan_dispatches, not something I have observed directly, and I am labelling it as such.Neither appears in a runner-seconds figure, so a rule built only on occupancy is blind to both.
test(actions): anchor cancel-in-progress contracts so a flipped flag …
seonghobae commentedon Sep 6, 2026
Correction: the 118.4 h I published is queue residency plus runner time, and the colleague I overruled was right
I reported 118.4 h of "occupancy" destroyed by cancelling 66 push-triggered
strix.ymlruns, computedas the sum of each job's
started_at → completed_at. That figure conflates two different things, andthe API distinguishes them directly.
A job object carries
runner_id. When a runner is actually allocated it holds an integer andrunner_nameis populated; when the job asked for one and never got it,runner_idis0andrunner_nameis""; when it was never scheduled at all, both arenull. Re-running the same 66 runs(66 queried, 0 API failures) and splitting on that field:
runner_idrunner_id0 or nullRoughly two thirds of what I called occupancy was time spent waiting in line, not runner
consumption.
I overturned a correct measurement with a worse one
A parallel session first computed this figure as 41.3 h. I told them their filter
(
started_at > created_at) was dropping jobs whose queue wait was under a second, recomputed as118.4 h, and they accepted it. The runner-time total above is 41.3 h — the same number to the decimal.
Their filter was, in effect, selecting jobs that had been allocated a runner, which is the quantity a
reader of "occupancy" expects.
So the sequence was: they measured runner time, I replaced it with a mixed quantity and called the
replacement a correction, and both of us then published from mine.
Why the distinction changes the argument
The two campaign totals I highlighted — 71.1 h across two clusters, 60% of the window — need the same
split before they mean anything about runner capacity. Queue residency reclaimed by a cancellation is
not wasted runner time; it is a slot freed. The sweep rule I proposed still holds, but its stated
benefit ("preserve 71.1 h of runner time") is wrong as written: much of that is queue position, which
is exactly the distinction I argued for elsewhere in this issue and then failed to apply to my own
number.
The instrument rule
started_atis not evidence that a runner was allocated. A cancelled job that never received onestill carries a
started_at, andcompleted_at − started_atthen measures queue residency whilelooking exactly like execution time.
steps == 0is a hint;runner_idis the field that answers it.Same shape as two other proxies retired today in this thread — a
jobs-count proxy for a run'sstatus, and asteps == 0proxy for a cancellation's cause. In all three cases the API reports thefact directly and the derived signal pointed the wrong way.
seonghobae commentedon Sep 6, 2026
Consolidated retraction: four figures in this issue rest on a measurement I got wrong
Everything below follows from one error of mine, so it is cleanest to retract it in one place rather
than leave four partial corrections scattered up the thread.
The root error
I computed "occupancy" as the sum of each job's
completed_at − started_at. That counts time a jobspent waiting for a runner as if it were time executing. A job cancelled while queued still carries
a
started_at, so the arithmetic looks identical.The API reports allocation directly. Splitting the same 66 cancelled push-triggered
strix.ymlrunsby
runner_id(66 runs queried, 0 API failures, 281 jobs):runner_idsetrunner_id0 or nullTwo thirds of the number I published was time in line.
I overturned a correct measurement to produce it
A parallel session first reported 41.3 h, using a
started_at > created_atfilter. I called thatfilter a bug, recomputed 118.4 h, and they withdrew their figure. A confusion matrix over all 281 jobs:
Their filter and
runner_idare the same test on this data.steps > 0also agrees on all 281. Theirnumber was right; my "correction" replaced it with a mixed quantity.
Retracted
essentially no runner time. That sweep discarded almost nothing.
campaigns fall out of the top five entirely. The replacements are different events
(2026-09-05T16:36 at 13.4 h, 2026-09-06T04:35 at 8.8 h); their share is 54%, which resembles 60%
only by coincidence.
been waiting while three younger ones had been executing for 618–729 minutes, and concluded age
is anti-correlated with cost. By
runner_id, those three "executing" runs received no runner atall; the campaign's 34.3 h is 0.2 minutes of runner time. Neither the anti-correlation nor the
"worst possible order" claim has support.
What survives, on the corrected basis
The occupancy threshold still works, and the case for it is cleaner:
At 30 minutes, 52 of 66 cancellations are still permitted while discarding 12 minutes of runner
time and preserving 41.1 h.
But the story changes. It is not "sweeps kill expensive running work". It is "of 66 cancelled runs,
only about 16 ever held a runner, and the rule only has to protect those" — which is what two other
sessions said from the start and what my own notes already recorded as queued cancels cost zero
runner time. I overrode that with a bad measurement.
Unaffected: the pairing of an occupancy threshold with a subject predicate (has the head moved, is the
PR closed), and the finding that the subject predicate must be evaluated first and rejects most
candidates. Those never depended on the magnitude.
seonghobae commentedon Sep 7, 2026
2026-09-07 contract correction / owner coordination (no workflow mutation, no live model cancellation):
The user’s current target is narrower than invariant 2 as written. Do not mechanically replace every
cancel-in-progress: falsewithtrue, and do not treat elapsed model time as cancellation authority.Preserve the safety goal behind the old invariant while updating the executable contract to distinguish two authorities:
{workflow identity}-{target repository}-{PR number}. Trigger-awarecancel-in-progress: trueis acceptable only where the triggering event itself is authoritative enough that it cannot let a delayed/stale lifecycle event cancel the current same-head semantic run before live validation. Same-head lifecycle events (for exampleready_for_review) must preserve/reuse current authoritative work or deliberately replace it with an immediately materialized same-head successor; they are not evidence that the old run is a previous HEAD.Current implementation lineage:
7d707b8abbb8a3fed95d0efe4121ed9b4f76bb2a) and later exact live-ref / pre-cancel revalidation repairs;1b65dbc35e7183722ad77894e2d80b39993be90d) removed the duplicate organization-wide queue sweep, so org sweep is no longer the cancellation owner;fix(ci): protect current review evidence from stale runsis the current cross-Agent rerun/concurrency implementation owner. Its recorded remote head isa60163b6e193c9d70337094e3e86c9f3d92fc741; re-fetch before writes because concurrent movement is normal.Fresh operational observation also matters: converting #1706 to Ready on unchanged head
14344f7c…cancelled old same-head Strix run34068478185, while replacement same-head run34077290787materialized. That avoided sole-authority loss in that specimen but demonstrates whysame PRalone is insufficient cancellation authority: final tests must distinguish previous HEAD, same-head lifecycle successor, delayed stale event, and same-head rerun.Required regression matrix before rewriting this issue’s invariant text as final authority: current running same-head; current pending same-head; newer current head; delayed previous-head event; same-head ready/draft lifecycle; native vs repository_dispatch retry; closed PR; API/identity failure. Final expected behavior is trigger-aware admission plus previous-head-only destructive cleanup, while preserving stale-event admission protection and unbounded semantic model execution.
seonghobae commentedon Sep 13, 2026
2026-09-13 live evidence — same-head OpenCode dispatch duplicates survive queue admission
Protected
.github/main@78393ea901efc0d57b35dd1e383e473425b7c1daand.github#2170@d3f1d0264912e74b897fdf2a0a6085100de4e40aexpose a queue-state case that the current workflow-level coalescing contract does not cover.34765577017targetsContextualWisdomLab/.github#2170@d3f1d0264912e74b897fdf2a0a6085100de4e40a, created2026-09-13T15:25:54Z.validate-pr-metadataandcoverage-source-treecompleted successfully; the workflow is nowqueued, withcoverage-evidencejob103754698586itself stillqueuedafter those earlier jobs consumed runners.34769173792targets the same repository, PR, and exact head, created2026-09-13T16:38:07Z, and is simultaneouslypendingwith no jobs admitted yet.opencode-review-dispatch.ymlgroups only target repository + PR and uses workflow-levelcancel-in-progress: true;test_privileged_review_dispatch_coalesces_superseded_runs_before_admissionexplicitly expects this mechanism to discard superseded dispatches before they consume queue/runner capacity.This is not a
#2170scheduler-source failure. Its Required OpenCodecoverage-evidenceis GREEN; the Required OpenCode failure is the intentional fail-closed handoff while waiting for a current-head verdict. The live pair above is fresh evidence for this issue's OpenCode concurrency owner: repeated requests for one current exact head can coexist as an older partially-progressedqueueddispatch plus a newerpendingdispatch during fleet saturation.Successor RED should cover this exact shape before changing production:
repository_dispatchrequests for the same target repository/PR/head, where the first has already completed prerequisite jobs and is queued for a later job, must leave at most one live privileged dispatch/model-work item.orchestrator/freeand exact-head binding.I am leaving
#2170unchanged and treating this as#1800owner evidence rather than opening a competing repair lane.