11// Decides, from what the host and the managers' runs say now, which open pull
22// requests to queue again. Pure, so every recovery path is decided in one place.
3- import { PR_TRIAGE_STUCK_RUN_MS , type AwaitedStage , type PrTriageRow , type RepoSource } from "@corbits/triage-contracts" ;
3+ import { PR_TRIAGE_STUCK_RUN_MS , WAITING_FOR_CAPACITY , type AwaitedStage , type PrTriageRow , type RepoSource } from "@corbits/triage-contracts" ;
44import { MODEL_NOT_ASKED } from "@corbits/triage-workflows/logic/settled" ;
55
66/** `labels` only where the host lists them. */
@@ -81,6 +81,8 @@ export type TenantPlanInput = {
8181 repos : readonly RepoPlanInput [ ] ;
8282 /** Every observed run, by repository, then by `${number}@${headSha}`, whether or not its repository or head is still here. */
8383 runs : ReadonlyMap < string , ReadonlyMap < string , readonly ObservedRun [ ] > > ;
84+ /** By `pullKey`, the pull requests whose last manager every provisioner refused a placement for capacity. */
85+ roomless : ReadonlySet < string > ;
8486 now : Date ;
8587 policy : ReconcilePolicy ;
8688} ;
@@ -95,6 +97,10 @@ export function runKey(number: number, headSha: string): string {
9597 return `${ number } @${ headSha } ` ;
9698}
9799
100+ export function pullKey ( repo : string , number : number ) : string {
101+ return `${ repo } #${ number } ` ;
102+ }
103+
98104export function runKeyNumber ( key : string ) : number {
99105 return Number ( key . slice ( 0 , key . indexOf ( "@" ) ) ) ;
100106}
@@ -162,9 +168,11 @@ function sinceStale(row: PrTriageRow, runs: readonly ObservedRun[], policy: Reco
162168 * only sees runs since it was queued, so a head queued again on top of a
163169 * verdict stays queued until its new run starts. Runs from before the base
164170 * branch last moved settle nothing; one of them in flight holds the head
165- * until it ends, and the head is stale then.
171+ * until it ends, and the head is stale then. A queued head whose last manager
172+ * was refused for capacity stays queued, never aging, until a manager deployed
173+ * since is not refused.
166174 */
167- function observe ( row : PrTriageRow , observed : readonly ObservedRun [ ] , now : Date , policy : ReconcilePolicy ) : PrTriageRow {
175+ function observe ( row : PrTriageRow , observed : readonly ObservedRun [ ] , roomless : boolean , now : Date , policy : ReconcilePolicy ) : PrTriageRow {
168176 const at = now . toISOString ( ) ;
169177 const waiting = row . status === "queued" || row . status === "running" ;
170178 const current = sinceStale ( row , observed , policy ) ;
@@ -185,6 +193,12 @@ function observe(row: PrTriageRow, observed: readonly ObservedRun[], now: Date,
185193 const failed = withStatus ( row , { status : "failed" , run : ended , error : failure ( ended , isStuck ( ended , now , policy ) ) } , at ) ;
186194 return row . status === "triaged" && ended . unsettled === MODEL_NOT_ASKED ? { ...failed , attempts : 0 } : failed ;
187195 }
196+ if ( row . status === "queued" && roomless ) return withStatus ( row , { status : "queued" , error : WAITING_FOR_CAPACITY } , at ) ;
197+ if ( row . status === "queued" && row . error === WAITING_FOR_CAPACITY ) {
198+ // A manager deployed since the refusal was not refused, so the head waits for it from now as any queued head does.
199+ const { error : _error , ...rest } = row ;
200+ return { ...rest , queuedAt : at , updatedAt : at } ;
201+ }
188202 if ( row . status === "queued" && row . queuedAt !== undefined && now . getTime ( ) - ms ( row . queuedAt ) > policy . unstartedAfterMs ) {
189203 return withStatus ( row , { status : "failed" , error : NEVER_STARTED } , at ) ;
190204 }
@@ -208,6 +222,8 @@ function isDue(row: PrTriageRow, pr: OpenPr, runs: readonly ObservedRun[], now:
208222 if ( row . status === "new" ) return now . getTime ( ) - ms ( pr . updatedAt ) >= policy . webhookGraceMs ;
209223 // Mail that reached no listener says nothing about the head, so it goes out again on the first pass a listener is up.
210224 if ( row . status === "failed" && row . unrouted ) return true ;
225+ // A head waiting for capacity asks for a manager again on every pass; the hub's hold on a recent ask paces the deploys.
226+ if ( row . status === "queued" && row . error === WAITING_FOR_CAPACITY ) return true ;
211227 if ( row . status !== "failed" || row . attempts >= policy . maxAttempts ) return false ;
212228 const last = row . queuedAt === undefined ? - Infinity : ms ( row . queuedAt ) ;
213229 const verdict = runs . find ( ( run ) => run . runId === row . runId ) ;
@@ -281,7 +297,7 @@ export function requeue(plan: RepoPlan, row: PrTriageRow, reason: string, now: D
281297}
282298
283299/** Queues due heads across the tenant's repositories, never-triaged ones first, while fewer than `maxInFlight` heads are queued or running, a manager waiting for CI aside. */
284- export function planTenant ( { repos, runs, now, policy } : TenantPlanInput ) : Map < string , RepoPlan > {
300+ export function planTenant ( { repos, runs, roomless , now, policy } : TenantPlanInput ) : Map < string , RepoPlan > {
285301 const at = now . toISOString ( ) ;
286302 const plans = new Map < string , RepoPlan > ( ) ;
287303 const due : Due [ ] = [ ] ;
@@ -294,18 +310,22 @@ export function planTenant({ repos, runs, now, policy }: TenantPlanInput): Map<s
294310 const key = runKey ( pr . number , pr . headSha ) ;
295311 const current = fresh ( pr , byHead . get ( key ) , at ) ;
296312 const runsOfHead = observed ?. get ( key ) ?? [ ] ;
297- const row = uncapped ( observe ( current , runsOfHead , now , policy ) , now , policy ) ;
313+ const row = uncapped ( observe ( current , runsOfHead , roomless . has ( pullKey ( name , pr . number ) ) , now , policy ) , now , policy ) ;
298314 if ( inFlight ( row ) && ! parked ( runsOfHead ) ) busy . add ( `${ name } ${ key } ` ) ;
299315 if ( isDue ( row , pr , runsOfHead , now , policy ) ) due . push ( { repo : name , index : next . length , row } ) ;
300316 next . push ( row ) ;
301317 }
302318 plans . set ( name , { rows : next , enqueue : [ ] } ) ;
303319 }
304- for ( const { repo, index, row } of due . sort ( byWait ) . slice ( 0 , Math . max ( policy . maxInFlight - busy . size , 0 ) ) ) {
320+ // A head waiting for capacity already holds its slot, so it asks again whatever the others leave free.
321+ const holding = due . filter ( ( entry ) => inFlight ( entry . row ) ) ;
322+ const next = due . filter ( ( entry ) => ! inFlight ( entry . row ) ) . sort ( byWait ) . slice ( 0 , Math . max ( policy . maxInFlight - busy . size , 0 ) ) ;
323+ for ( const { repo, index, row } of [ ...holding , ...next ] ) {
305324 const plan = plans . get ( repo ) ! ;
306- const { runId : _run , error : _error , unrouted, ...rest } = row ;
307- const attempts = unrouted ? row . attempts : row . attempts + 1 ;
308- plan . rows [ index ] = { ...rest , status : "queued" , attempts, queuedAt : at , updatedAt : at } ;
325+ const { runId : _run , error, unrouted, ...rest } = row ;
326+ const roomless = error === WAITING_FOR_CAPACITY ;
327+ const attempts = unrouted || roomless ? row . attempts : row . attempts + 1 ;
328+ plan . rows [ index ] = { ...rest , status : "queued" , attempts, queuedAt : at , updatedAt : at , ...( roomless && { error } ) } ;
309329 const reason = row . error ?? "never triaged" ;
310330 const noWait = decideNow ( attempts , policy ) ;
311331 plan . enqueue . push ( { number : row . number , reason, ...( noWait && { noWait } ) } ) ;
0 commit comments