@joshuafolkken/kit 1.1229.0 → 1.1240.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.
Files changed (67) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-lanes.md +1 -1
  2. package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -2
  3. package/.claude/skills/workflow-commands/followup.md +5 -0
  4. package/.claude/skills/workflow-commands/kickoff.md +1 -0
  5. package/.claude/skills/workflow-commands/latest-gate.md +1 -1
  6. package/.github/workflows/publish.yml +2 -0
  7. package/README.md +30 -31
  8. package/dist/hooks/batch-guard.js +4 -4
  9. package/dist/hooks/{chunk-JUDKDCDP.js → chunk-4TZVB44A.js} +8 -8
  10. package/dist/hooks/{chunk-RNWHN5BP.js → chunk-F3ELQ2AF.js} +3 -3
  11. package/dist/hooks/{chunk-VC3PZPZO.js → chunk-GPTFMK5E.js} +1 -1
  12. package/dist/hooks/{chunk-FIVDHQGT.js → chunk-KHGB5S3Y.js} +1 -1
  13. package/dist/hooks/{chunk-NV2JRZZO.js → chunk-KHX4U3BG.js} +17 -2
  14. package/dist/hooks/{chunk-F56MFS4B.js → chunk-KRBERBBI.js} +3 -3
  15. package/dist/hooks/{chunk-TQNPDZNH.js → chunk-QVSWU24O.js} +4 -4
  16. package/dist/hooks/{chunk-55HHG5PX.js → chunk-WK2XSHSI.js} +284 -63
  17. package/dist/hooks/{chunk-5VMKCCC4.js → chunk-XZYKEM6J.js} +2 -2
  18. package/dist/hooks/{chunk-6HJJXRBX.js → chunk-ZQGD4GBY.js} +1 -1
  19. package/dist/hooks/codex-hook-adapter.js +10 -10
  20. package/dist/hooks/duplicate-read-guard.js +5 -5
  21. package/dist/hooks/format-edited.js +2 -2
  22. package/dist/hooks/inputs.json +172 -170
  23. package/dist/hooks/investigation-guard.js +5 -5
  24. package/dist/hooks/pretool-guard.js +9 -9
  25. package/dist/hooks/stop-guard.js +3 -17
  26. package/dist/josh.js +279 -63
  27. package/package.json +1 -1
  28. package/prompts/collaboration-workflow/issue-template.md +1 -0
  29. package/scripts/gate/core-admission.ts +162 -0
  30. package/scripts/gate/core-budget.ts +121 -67
  31. package/scripts/gate/gate-plan.ts +35 -2
  32. package/scripts/gate/gate-report.ts +28 -9
  33. package/scripts/gate/machine-capacity.ts +175 -0
  34. package/scripts/gate/verification-gate.ts +7 -2
  35. package/scripts/init/init.ts +5 -1
  36. package/scripts/init/package-manager-pin.ts +35 -0
  37. package/scripts/issue/issue-auto-ok.ts +31 -6
  38. package/scripts/issue/issue-file-cli.ts +31 -8
  39. package/scripts/issue/issue-file.ts +5 -0
  40. package/scripts/issue/issue-labels.ts +13 -0
  41. package/scripts/issue/issue-release-cli.ts +155 -0
  42. package/scripts/issue/issue-release.ts +58 -0
  43. package/scripts/josh/josh-command-types.ts +3 -0
  44. package/scripts/josh/josh-commands-development.ts +8 -0
  45. package/scripts/josh/josh-commands-issue.ts +8 -1
  46. package/scripts/josh/josh-commands-lane.ts +2 -1
  47. package/scripts/josh/josh-commands-maintenance.ts +2 -2
  48. package/scripts/josh/josh-logic.ts +4 -3
  49. package/scripts/lane/lane-cli.ts +9 -1
  50. package/scripts/lane/lane-leftover.ts +43 -29
  51. package/scripts/lane/lane-open.ts +5 -5
  52. package/scripts/lane/lane-paths.ts +13 -0
  53. package/scripts/lane/lane-registry.ts +11 -0
  54. package/scripts/lane/lane-stray.ts +140 -0
  55. package/scripts/release/github-release-environment.ts +1 -0
  56. package/scripts/release/github-release-runs.ts +115 -0
  57. package/scripts/release/github-release.ts +13 -67
  58. package/scripts/run/cut/run-cut-cli.ts +16 -3
  59. package/scripts/run/event/run-event-render.ts +1 -0
  60. package/scripts/run/event/run-event-stream.ts +4 -0
  61. package/scripts/run/run-step.ts +5 -2
  62. package/scripts/run/wake/run-wake-cli.ts +2 -3
  63. package/scripts/run/wake/run-wake-describe.ts +56 -5
  64. package/scripts/run/wake/run-wake-loop.ts +5 -13
  65. package/scripts/run/wake/run-wake.ts +15 -64
  66. package/scripts/version/latest-corepack.ts +109 -78
  67. package/templates/workflows/github-release.yml +4 -0
@@ -0,0 +1,162 @@
1
+ import type { LedgerLoad, MachineBudget } from './machine-capacity'
2
+
3
+ // The ledger's admission arithmetic, kept apart from the markers `core-budget.ts` reads and writes so
4
+ // that file stays under its line ceiling as the budget grew a memory side (joshuafolkken/kit#3371).
5
+ // Everything here is a pure function of the ledger it is handed.
6
+
7
+ const MS_PER_SECOND = 1000
8
+ // **How long an admitted claim's tool takes to reach its declared peak.** The machine reading holds only
9
+ // what a tool has already allocated, so a claim admitted moments ago is not in it yet: adding its
10
+ // declared memory back would admit the next claim into memory about to be taken — the concurrent start
11
+ // this ledger exists to stop. Until the window passes, the claim's memory counts only in the FIFO walk; a
12
+ // tool partway up is then counted twice, which costs a slower admission, never a swap. Its cores are in
13
+ // the reading from the start, so the window does not apply to them.
14
+ const RAMP_UP_MS = 30_000
15
+
16
+ // What admission reads from a marker. `memory_mb` and `admitted_at` are optional because a marker an
17
+ // older `josh` wrote has neither: it declares no memory, and it counts as load from outside the ledger.
18
+ interface LedgerReservation {
19
+ weight: number
20
+ claimed_at: number
21
+ memory_mb?: number | undefined
22
+ admitted_at?: number | undefined
23
+ }
24
+
25
+ interface LedgerEntry {
26
+ // The marker's filename, which is its identity in the ledger and the tiebreak when two reservations
27
+ // share a claim time.
28
+ key: string
29
+ reservation: LedgerReservation
30
+ }
31
+
32
+ // A claim admitted at the wait cap rather than by room in the budget — the run went ahead past the
33
+ // budget, and the gate's summary says so.
34
+ interface Overflow {
35
+ waited_ms: number
36
+ ledger: LedgerLoad
37
+ budget: MachineBudget
38
+ }
39
+
40
+ interface Admission {
41
+ overflow: Overflow | undefined
42
+ }
43
+
44
+ // Claim order, with the filename as the tiebreak so every reader agrees on the same ledger order
45
+ // however the wall clock rounded two near-simultaneous claims.
46
+ function compare_claim(left: LedgerEntry, right: LedgerEntry): number {
47
+ const by_time = left.reservation.claimed_at - right.reservation.claimed_at
48
+
49
+ return by_time === 0 ? left.key.localeCompare(right.key) : by_time
50
+ }
51
+
52
+ function add_load(totals: LedgerLoad, reservation: LedgerReservation): void {
53
+ totals.cores += reservation.weight
54
+ totals.memory_mb += reservation.memory_mb ?? 0
55
+ }
56
+
57
+ function exceeds(totals: LedgerLoad, budget: MachineBudget): boolean {
58
+ return totals.cores > budget.cores || totals.memory_mb > budget.memory_mb
59
+ }
60
+
61
+ // **The keys admitted under FIFO.** The head always runs — a single check heavier than the whole
62
+ // machine must not wait for room that will never come — and each later reservation runs while the
63
+ // cumulative weight ahead of it still fits the budget. The first that does not fit stops the walk, so a
64
+ // place is never jumped: admission is in claim order, which is what removes the start-time asymmetry.
65
+ // Memory is walked the same way, so a claim whose tool would not fit in free memory waits like one whose
66
+ // cores would not fit (joshuafolkken/kit#3371).
67
+ function admitted_keys(
68
+ reservations: ReadonlyArray<LedgerEntry>,
69
+ budget: number,
70
+ memory_budget = Infinity,
71
+ ): Set<string> {
72
+ const limit = { cores: budget, memory_mb: memory_budget }
73
+ const totals = { cores: 0, memory_mb: 0 }
74
+ const admitted = new Set<string>()
75
+
76
+ for (const [index, entry] of reservations.toSorted(compare_claim).entries()) {
77
+ add_load(totals, entry.reservation)
78
+
79
+ if (index !== 0 && exceeds(totals, limit)) break
80
+
81
+ admitted.add(entry.key)
82
+ }
83
+
84
+ return admitted
85
+ }
86
+
87
+ function is_admitted(
88
+ key: string,
89
+ reservations: ReadonlyArray<LedgerEntry>,
90
+ budget: MachineBudget,
91
+ ): boolean {
92
+ return admitted_keys(reservations, budget.cores, budget.memory_mb).has(key)
93
+ }
94
+
95
+ function sum_load(reservations: ReadonlyArray<LedgerEntry>): LedgerLoad {
96
+ const load = { cores: 0, memory_mb: 0 }
97
+
98
+ for (const entry of reservations) add_load(load, entry.reservation)
99
+
100
+ return load
101
+ }
102
+
103
+ function is_ramped(reservation: LedgerReservation, now: number): boolean {
104
+ const { admitted_at } = reservation
105
+
106
+ return admitted_at !== undefined && now - admitted_at >= RAMP_UP_MS
107
+ }
108
+
109
+ // **The load the ledger already accounts for.** CPU use shows in the reading the moment a tool starts,
110
+ // so every admitted claim's cores count at once; memory grows as the tool allocates, so only the claims
111
+ // admitted long enough ago to be in the reading count their memory.
112
+ function ledger_load(reservations: ReadonlyArray<LedgerEntry>, now: number): LedgerLoad {
113
+ const admitted = reservations.filter((entry) => entry.reservation.admitted_at !== undefined)
114
+ const ramped = admitted.filter((entry) => is_ramped(entry.reservation, now))
115
+
116
+ return { cores: sum_load(admitted).cores, memory_mb: sum_load(ramped).memory_mb }
117
+ }
118
+
119
+ function total_weight(reservations: ReadonlyArray<LedgerEntry>): number {
120
+ return sum_load(reservations).cores
121
+ }
122
+
123
+ // The weight actually running at once — what the reproduction test asserts stays within the budget.
124
+ function admitted_load(reservations: ReadonlyArray<LedgerEntry>, budget: number): number {
125
+ const admitted = admitted_keys(reservations, budget)
126
+
127
+ return total_weight(reservations.filter((entry) => admitted.has(entry.key)))
128
+ }
129
+
130
+ // The memory side of an overflow, so a claim held back by memory is not reported as a core shortfall.
131
+ // A budget that read no memory set no memory limit, and says nothing.
132
+ function describe_memory(ledger: LedgerLoad, budget: MachineBudget): string {
133
+ if (!Number.isFinite(budget.memory_mb)) return ''
134
+
135
+ return ` and ${String(Math.round(ledger.memory_mb))} MB of memory against ${String(Math.round(budget.memory_mb))} MB`
136
+ }
137
+
138
+ // The summary line for a claim admitted past the budget, or undefined for one that fitted.
139
+ function describe_overflow(admission: Admission): string | undefined {
140
+ const { overflow } = admission
141
+
142
+ if (overflow === undefined) return undefined
143
+
144
+ const { ledger, budget } = overflow
145
+ const waited = String(Math.round(overflow.waited_ms / MS_PER_SECOND))
146
+ const cores = `the ledger held ${String(ledger.cores)} cores against a budget of ${String(budget.cores)}`
147
+
148
+ return `started past the core budget after ${waited}s — ${cores}${describe_memory(ledger, budget)}`
149
+ }
150
+
151
+ const core_admission = {
152
+ RAMP_UP_MS,
153
+ admitted_keys,
154
+ admitted_load,
155
+ describe_overflow,
156
+ is_admitted,
157
+ ledger_load,
158
+ sum_load,
159
+ }
160
+
161
+ export type { Admission, LedgerEntry, Overflow }
162
+ export { core_admission }
@@ -8,6 +8,8 @@ import { process_owner_schema } from '#scripts/josh/process-owner'
8
8
  import { stamp_file } from '#scripts/josh/stamp-file'
9
9
  import { unit_worker_share } from '#scripts/test/unit-worker-share'
10
10
  import { z } from 'zod'
11
+ import { core_admission, type Admission, type LedgerEntry, type Overflow } from './core-admission'
12
+ import { machine_capacity, type MachineBudget, type MachineReading } from './machine-capacity'
11
13
 
12
14
  // A machine-wide weighted core budget with admission control (joshuafolkken/kit#2351).
13
15
  //
@@ -34,6 +36,11 @@ import { z } from 'zod'
34
36
  // killed outright leaves its marker behind, and a ledger that trusted the file alone would fill forever
35
37
  // and admit nothing. The pid-and-start-time pair a marker carries is the same identity the unit-run
36
38
  // marker keeps, swept on read the moment its process is gone.
39
+ //
40
+ // **The budget is what the machine has free, in cores and in memory** (joshuafolkken/kit#3371). The
41
+ // core count stays the ceiling, but load from outside the ledger is subtracted from it, and a claim
42
+ // waits while the memory its tool holds is not free — `machine-capacity.ts` reads both. A claim marks
43
+ // its marker admitted once it runs, so the reading's load is split into the ledger's and the rest.
37
44
 
38
45
  const RESERVED_PREFIX = 'josh-core-reserved-'
39
46
  const RESERVED_SUFFIX = '.json'
@@ -60,6 +67,19 @@ const CORE_WEIGHTS = {
60
67
  eslint_scan: 1,
61
68
  } as const
62
69
 
70
+ // **The memory each heavy tool holds, declared beside its cores** (joshuafolkken/kit#3371): peak
71
+ // resident size in MB, rounded up. Measured with `/usr/bin/time -l` on the 11-core / 18 GB machine on
72
+ // 2026-10-07: the type check 1,244 MB, the spell check 330 MB, and one unit worker 319 MB over 54 test
73
+ // files. Typed eslint was 1,207 MB over one directory and was observed at 1.6–2.4 GB per whole-tree
74
+ // `eslint .` with three lanes running the same day, so lint and the whole-tree scan take that peak.
75
+ const MEMORY_MB = {
76
+ lint: 2400,
77
+ type_check: 1300,
78
+ spell_check: 350,
79
+ eslint_scan: 2400,
80
+ unit_worker: 350,
81
+ } as const
82
+
63
83
  // **The environment mark that says a parent already holds this process's cores**
64
84
  // (joshuafolkken/kit#3345). `josh gate` reserves per check and then spawns `pnpm josh lint`. Without
65
85
  // the mark, that child's own dispatch would claim a second place for the cores its parent already
@@ -67,22 +87,27 @@ const CORE_WEIGHTS = {
67
87
  const HELD_KEY = 'JOSH_CORE_RESERVED'
68
88
  const HELD_VALUE = '1'
69
89
 
90
+ // `memory_mb` and `admitted_at` are optional so a marker an older `josh` wrote still reads: it holds no
91
+ // declared memory, and it counts as load from outside the ledger until it is gone.
70
92
  const reservation_schema = process_owner_schema.extend({
71
93
  weight: z.number(),
72
94
  claimed_at: z.number(),
95
+ memory_mb: z.number().optional(),
96
+ admitted_at: z.number().optional(),
73
97
  })
74
98
 
75
99
  type Reservation = z.infer<typeof reservation_schema>
76
100
 
77
- interface LiveReservation {
78
- // The marker's filename, which is its identity in the ledger and the tiebreak when two reservations
79
- // share a claim time.
80
- key: string
101
+ type ReservationFields = Pick<Reservation, 'weight' | 'claimed_at' | 'memory_mb' | 'admitted_at'>
102
+
103
+ interface LiveReservation extends LedgerEntry {
81
104
  reservation: Reservation
82
105
  }
83
106
 
84
107
  interface ReserveOptions {
85
108
  budget?: number
109
+ memory_mb?: number | undefined
110
+ read_machine?: () => Promise<MachineReading>
86
111
  wait_cap_ms?: number
87
112
  poll_interval_ms?: number
88
113
  directory?: string
@@ -92,6 +117,7 @@ interface ReserveOptions {
92
117
 
93
118
  interface Handle {
94
119
  target: string
120
+ admission: Admission
95
121
  }
96
122
 
97
123
  function marker_name(key: string): string {
@@ -141,10 +167,13 @@ function live_reservation(source: string): Reservation | undefined {
141
167
  }
142
168
 
143
169
  // An unreadable temp directory answers "no other reservation", the direction that admits rather than
144
- // stalls a run on a guess.
170
+ // stalls a run on a guess. The suffix leaves out `replace_stamp`'s in-flight temporary copy of a marker,
171
+ // which would otherwise count one place twice.
145
172
  function marker_files(directory: string): Array<string> {
146
173
  try {
147
- return readdirSync(directory).filter((name) => name.startsWith(RESERVED_PREFIX))
174
+ return readdirSync(directory).filter(
175
+ (name) => name.startsWith(RESERVED_PREFIX) && name.endsWith(RESERVED_SUFFIX),
176
+ )
148
177
  } catch {
149
178
  return []
150
179
  }
@@ -158,50 +187,6 @@ function live_reservations(directory: string = PLATFORM_TEMP_ROOT): Array<LiveRe
158
187
  })
159
188
  }
160
189
 
161
- // Claim order, with the filename as the tiebreak so every reader agrees on the same ledger order
162
- // however the wall clock rounded two near-simultaneous claims.
163
- function compare_claim(left: LiveReservation, right: LiveReservation): number {
164
- const by_time = left.reservation.claimed_at - right.reservation.claimed_at
165
-
166
- return by_time === 0 ? left.key.localeCompare(right.key) : by_time
167
- }
168
-
169
- // **The keys admitted under FIFO.** The head always runs — a single check heavier than the whole
170
- // machine must not wait for room that will never come — and each later reservation runs while the
171
- // cumulative weight ahead of it still fits the budget. The first that does not fit stops the walk, so a
172
- // place is never jumped: admission is in claim order, which is what removes the start-time asymmetry.
173
- function admitted_keys(reservations: ReadonlyArray<LiveReservation>, budget: number): Set<string> {
174
- const admitted = new Set<string>()
175
- let cumulative = 0
176
-
177
- for (const [index, entry] of reservations.toSorted(compare_claim).entries()) {
178
- cumulative += entry.reservation.weight
179
-
180
- if (index !== 0 && cumulative > budget) break
181
-
182
- admitted.add(entry.key)
183
- }
184
-
185
- return admitted
186
- }
187
-
188
- function is_admitted(
189
- key: string,
190
- reservations: ReadonlyArray<LiveReservation>,
191
- budget: number,
192
- ): boolean {
193
- return admitted_keys(reservations, budget).has(key)
194
- }
195
-
196
- // The weight actually running at once — what the reproduction test asserts stays within the budget.
197
- function admitted_load(reservations: ReadonlyArray<LiveReservation>, budget: number): number {
198
- const admitted = admitted_keys(reservations, budget)
199
-
200
- return reservations
201
- .filter((entry) => admitted.has(entry.key))
202
- .reduce((total, entry) => total + entry.reservation.weight, 0)
203
- }
204
-
205
190
  // `Math.max` propagates `NaN` rather than clamping it, so an unusable budget is caught before it could
206
191
  // make every comparison false and admit everything — the hole `gate-plan.ts` closes for the same reason.
207
192
  function normalize_budget(budget: number): number {
@@ -214,23 +199,30 @@ async function default_sleep(ms: number): Promise<void> {
214
199
  })
215
200
  }
216
201
 
217
- function write_reservation(target: string, weight: number, claimed_at: number): void {
218
- stamp_file.write_stamp(target, { ...process_identity.own_fields(), weight, claimed_at })
202
+ // **Replaced atomically, never unlinked and recreated**: the admitted rewrite lands on a marker other
203
+ // lanes are polling, and a reader that hit the gap between an unlink and its create would see the ledger
204
+ // without this place and admit past it.
205
+ function write_reservation(target: string, fields: ReservationFields): void {
206
+ stamp_file.replace_stamp(target, { ...process_identity.own_fields(), ...fields })
219
207
  }
220
208
 
221
209
  // The clock and cadence a wait runs on, resolved apart from the ledger fields so `reserve` stays under
222
- // the complexity limit — each default is a branch, and six in one function is past it.
210
+ // the complexity limit — each default is a branch.
223
211
  interface TimingConfig {
224
212
  now: () => number
225
213
  sleep: (ms: number) => Promise<void>
226
214
  poll_interval_ms: number
215
+ wait_cap_ms: number
227
216
  }
228
217
 
229
218
  interface WaitContext extends TimingConfig {
219
+ read_machine: () => Promise<MachineReading>
230
220
  key: string
221
+ target: string
231
222
  directory: string
232
223
  budget: number
233
224
  deadline: number
225
+ fields: ReservationFields
234
226
  }
235
227
 
236
228
  function resolve_timing(options: ReserveOptions): TimingConfig {
@@ -238,39 +230,96 @@ function resolve_timing(options: ReserveOptions): TimingConfig {
238
230
  now: options.now ?? Date.now,
239
231
  sleep: options.sleep ?? default_sleep,
240
232
  poll_interval_ms: options.poll_interval_ms ?? POLL_INTERVAL_MS,
233
+ wait_cap_ms: options.wait_cap_ms ?? WAIT_CAP_MS,
241
234
  }
242
235
  }
243
236
 
237
+ // **A claim alone in the ledger reads nothing from the machine**: the head is admitted whatever the
238
+ // budget says, so a solo run pays no sampling window and starts exactly as it did before.
239
+ async function current_budget(
240
+ context: WaitContext,
241
+ reservations: ReadonlyArray<LiveReservation>,
242
+ ): Promise<MachineBudget> {
243
+ if (reservations.length <= 1) return { cores: context.budget, memory_mb: Infinity }
244
+
245
+ const reading = await context.read_machine()
246
+ const ledger = core_admission.ledger_load(reservations, context.now())
247
+ const budget = machine_capacity.machine_budget(context.budget, reading, ledger)
248
+
249
+ return { ...budget, cores: normalize_budget(budget.cores) }
250
+ }
251
+
252
+ // The claim is admitted: its marker records when, which moves its load from "load outside the ledger"
253
+ // to "load the ledger holds" for every later reader — its cores at once, its memory once its tool has
254
+ // had time to ramp up.
255
+ function admit(context: WaitContext, overflow?: Overflow): Admission {
256
+ write_reservation(context.target, { ...context.fields, admitted_at: context.now() })
257
+
258
+ return { overflow }
259
+ }
260
+
261
+ function overflow_of(
262
+ context: WaitContext,
263
+ reservations: ReadonlyArray<LiveReservation>,
264
+ budget: MachineBudget,
265
+ ): Overflow {
266
+ return {
267
+ waited_ms: context.now() - context.fields.claimed_at,
268
+ ledger: core_admission.sum_load(reservations),
269
+ budget,
270
+ }
271
+ }
272
+
273
+ async function poll_admission(context: WaitContext): Promise<Admission | undefined> {
274
+ const reservations = live_reservations(context.directory)
275
+ const budget = await current_budget(context, reservations)
276
+
277
+ if (core_admission.is_admitted(context.key, reservations, budget)) return admit(context)
278
+
279
+ if (context.now() >= context.deadline) {
280
+ return admit(context, overflow_of(context, reservations, budget))
281
+ }
282
+
283
+ return undefined
284
+ }
285
+
244
286
  // Poll the ledger until this reservation is admitted, or until the wait cap admits it at minimum width.
245
287
  // A solo run is admitted on the first read — its own claim is the ledger's head — so the loop body never
246
288
  // runs and the wait costs nothing.
247
- async function await_admission(context: WaitContext): Promise<void> {
248
- while (!is_admitted(context.key, live_reservations(context.directory), context.budget)) {
249
- if (context.now() >= context.deadline) return
289
+ async function await_admission(context: WaitContext): Promise<Admission> {
290
+ let admission = await poll_admission(context)
250
291
 
292
+ while (admission === undefined) {
251
293
  // eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
252
294
  await context.sleep(context.poll_interval_ms)
295
+ // eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
296
+ admission = await poll_admission(context)
253
297
  }
298
+
299
+ return admission
254
300
  }
255
301
 
256
302
  async function reserve(weight: number, options: ReserveOptions = {}): Promise<Handle> {
257
303
  const timing = resolve_timing(options)
258
304
  const directory = options.directory ?? PLATFORM_TEMP_ROOT
259
- const budget = normalize_budget(options.budget ?? availableParallelism())
260
305
  const claimed_at = timing.now()
261
306
  const name = marker_name(randomUUID())
262
307
  const target = path.join(directory, name)
308
+ const fields = { weight, claimed_at, memory_mb: options.memory_mb }
263
309
 
264
- write_reservation(target, weight, claimed_at)
265
- await await_admission({
310
+ write_reservation(target, fields)
311
+ const admission = await await_admission({
266
312
  ...timing,
267
313
  key: name,
314
+ target,
268
315
  directory,
269
- budget,
270
- deadline: claimed_at + (options.wait_cap_ms ?? WAIT_CAP_MS),
316
+ budget: normalize_budget(options.budget ?? availableParallelism()),
317
+ deadline: claimed_at + timing.wait_cap_ms,
318
+ fields,
319
+ read_machine: options.read_machine ?? machine_capacity.read_machine,
271
320
  })
272
321
 
273
- return { target }
322
+ return { target, admission }
274
323
  }
275
324
 
276
325
  function release(handle: Handle): void {
@@ -278,16 +327,17 @@ function release(handle: Handle): void {
278
327
  }
279
328
 
280
329
  // The whole reservation lifetime around a step: claim a place, wait for admission, run, and free the
281
- // place on any exit. `finally`, so a step that threw still releases its cores.
330
+ // place on any exit. `finally`, so a step that threw still releases its cores. The step is told how it was
331
+ // admitted, so the gate can report one that started past the budget.
282
332
  async function with_core_reservation<T>(
283
333
  weight: number,
284
- run: () => Promise<T>,
334
+ run: (admission: Admission) => Promise<T>,
285
335
  options: ReserveOptions = {},
286
336
  ): Promise<T> {
287
337
  const handle = await reserve(weight, options)
288
338
 
289
339
  try {
290
- return await run()
340
+ return await run(handle.admission)
291
341
  } finally {
292
342
  release(handle)
293
343
  }
@@ -338,9 +388,11 @@ const core_budget = {
338
388
  CORE_WEIGHTS,
339
389
  HELD_KEY,
340
390
  HELD_VALUE,
391
+ MEMORY_MB,
341
392
  RESERVED_PREFIX,
342
- admitted_keys,
343
- admitted_load,
393
+ admitted_keys: core_admission.admitted_keys,
394
+ admitted_load: core_admission.admitted_load,
395
+ describe_overflow: core_admission.describe_overflow,
344
396
  is_held,
345
397
  live_reservations,
346
398
  release,
@@ -352,3 +404,5 @@ const core_budget = {
352
404
 
353
405
  export type { LiveReservation, Reservation, ReserveOptions }
354
406
  export { core_budget }
407
+
408
+ export { type Admission } from './core-admission'
@@ -36,6 +36,9 @@ interface GateCheck {
36
36
  // The unit suite reserves nothing: it sizes its own pool from the machine, so it is capped
37
37
  // instead of reserved, and counting it here would reserve cores against itself.
38
38
  reserved_cores: number
39
+ // The memory this check holds while it runs, `core_budget.MEMORY_MB`'s measured peak; none declared is
40
+ // none waited for (joshuafolkken/kit#3371). The unit suite's is per worker, so it is derived instead.
41
+ memory_mb?: number
39
42
  }
40
43
 
41
44
  const LINT_LABEL = 'lint'
@@ -74,13 +77,24 @@ const WARNING_CHECKER_LABELS: ReadonlyArray<string> = [LINT_LABEL, TYPE_CHECK_LA
74
77
  // full length of the unit suite, to cover a burst that ends before lint does. Nothing has measured
75
78
  // that trade, so the measured shape stays.
76
79
  const STATIC_CHECKS: ReadonlyArray<GateCheck> = [
77
- { label: LINT_LABEL, target: 'lint', reserved_cores: core_budget.CORE_WEIGHTS.lint },
80
+ {
81
+ label: LINT_LABEL,
82
+ target: 'lint',
83
+ reserved_cores: core_budget.CORE_WEIGHTS.lint,
84
+ memory_mb: core_budget.MEMORY_MB.lint,
85
+ },
78
86
  {
79
87
  label: TYPE_CHECK_LABEL,
80
88
  target: 'check',
81
89
  reserved_cores: core_budget.CORE_WEIGHTS.type_check,
90
+ memory_mb: core_budget.MEMORY_MB.type_check,
91
+ },
92
+ {
93
+ label: 'cspell',
94
+ target: 'cspell:dot',
95
+ reserved_cores: core_budget.CORE_WEIGHTS.spell_check,
96
+ memory_mb: core_budget.MEMORY_MB.spell_check,
82
97
  },
83
- { label: 'cspell', target: 'cspell:dot', reserved_cores: core_budget.CORE_WEIGHTS.spell_check },
84
98
  { label: BEHAVIOR_LABEL, target: BEHAVIOR_LABEL, reserved_cores: 0 },
85
99
  // **The unused-member check reserves no core** (joshuafolkken/kit#2987): it builds one TypeScript
86
100
  // program and walks it for about 5s, and it is over well before lint and the type check. A
@@ -280,6 +294,18 @@ function check_weight(check: GateCheck, plan: GatePlan, available_cores: number)
280
294
  return plan.unit_worker_cap ?? solo_unit_weight(available_cores)
281
295
  }
282
296
 
297
+ function unit_memory(workers: number): number {
298
+ return workers * core_budget.MEMORY_MB.unit_worker
299
+ }
300
+
301
+ // The memory a check holds (joshuafolkken/kit#3371): its declared figure, or for the unit suite one
302
+ // worker's peak for each core it reserves — its weight is its worker count.
303
+ function check_memory(check: GateCheck, plan: GatePlan, available_cores: number): number {
304
+ if (check.label !== UNIT_LABEL) return check.memory_mb ?? 0
305
+
306
+ return unit_memory(check_weight(check, plan, available_cores))
307
+ }
308
+
283
309
  // The cores a unit run started directly reserves (joshuafolkken/kit#3345): the share it will pass to
284
310
  // vitest while other runs are live, and otherwise the gate's own solo unit weight. **Not the whole
285
311
  // machine**, though a lone vitest sizes its own pool: FIFO admission lets a claim of every core in only
@@ -298,6 +324,11 @@ function direct_unit_weight(
298
324
  return Math.max(unit_worker_share.MIN_WORKERS, weight)
299
325
  }
300
326
 
327
+ // What a unit run started directly holds: one worker's peak for each worker `direct_unit_weight` gives it.
328
+ function direct_unit_memory(): number {
329
+ return unit_memory(direct_unit_weight())
330
+ }
331
+
301
332
  // `availableParallelism()` rather than `cpus().length`: it reports what this process may actually
302
333
  // use, so a container with a CPU quota is sized by the quota rather than by the host.
303
334
  // The three fields together, for a caller that has already decided the numbers. The gate's own
@@ -383,7 +414,9 @@ const gate_plan = {
383
414
  TYPE_CHECK_LABEL,
384
415
  UNIT_LABEL,
385
416
  WARNING_CHECKER_LABELS,
417
+ check_memory,
386
418
  check_weight,
419
+ direct_unit_memory,
387
420
  direct_unit_weight,
388
421
  format_gate_plan,
389
422
  format_machine,
@@ -25,6 +25,8 @@ interface GateStepResult extends BufferedProcessResult {
25
25
  // What was actually run. The type check's command is resolved per project, so a failure on the
26
26
  // `check` step is only reproducible if the header names the command rather than the label.
27
27
  command: string
28
+ // Set when the check started past the machine-wide budget at the wait cap (joshuafolkken/kit#3371).
29
+ budget_note?: string | undefined
28
30
  }
29
31
 
30
32
  function is_gate_step_failed(result: GateStepResult): boolean {
@@ -141,25 +143,42 @@ function format_failure_actions(failed: ReadonlyArray<GateStepResult>): string {
141
143
  return `${GATE_COMMAND} — next:\n${lines.join('\n')}\n`
142
144
  }
143
145
 
146
+ // **A check that started past the core budget is said in the summary, not only in the log**
147
+ // (joshuafolkken/kit#3371). The wait cap admits a check that waited two minutes whatever the budget says,
148
+ // so a gate that ran over-subscribed would otherwise look exactly like one that fitted. The icon is
149
+ // local rather than in `status-icons.ts` because nothing reads this line back.
150
+ const WARN_ICON = '⚠'
151
+
152
+ function format_budget_notes(results: ReadonlyArray<GateStepResult>): string {
153
+ const lines = results.flatMap((result) =>
154
+ result.budget_note === undefined
155
+ ? []
156
+ : ` ${WARN_ICON} ${result.label} — ${result.budget_note}`,
157
+ )
158
+
159
+ return lines.length === 0 ? '' : `${lines.join('\n')}\n`
160
+ }
161
+
144
162
  // The total is wall-clock for the whole command, not the sum of the four — they run concurrently,
145
163
  // so a sum would report about three times what the caller waited.
146
164
  //
147
165
  // The failure actions and the log path are printed with the summary and immediately **above** the
148
166
  // verdict line, which stays last for the reason `josh-verdict.ts` gives (joshuafolkken/kit#1227).
149
167
  function print_gate_summary(
150
- failed: ReadonlyArray<GateStepResult>,
151
- elapsed_ms: number,
152
- step_count: string,
168
+ results: ReadonlyArray<GateStepResult>,
169
+ report: GateReport,
153
170
  log_path?: string,
154
171
  ): void {
155
- const total = format_seconds(elapsed_ms)
172
+ const failed = results.filter((result) => is_gate_step_failed(result))
173
+ const total = format_seconds(report.elapsed_ms)
156
174
  const verdict =
157
175
  failed.length === 0
158
- ? josh_verdict.format_gate_passed(step_count, total)
176
+ ? josh_verdict.format_gate_passed(report.step_count, total)
159
177
  : josh_verdict.format_gate_failed(failed.map((result) => result.label).join(', '), total)
178
+ const notes = format_budget_notes(results)
160
179
 
161
180
  process.stdout.write(
162
- `\n${format_failure_actions(failed)}${gate_log.format_log_notice(log_path)}${verdict}\n`,
181
+ `\n${notes}${format_failure_actions(failed)}${gate_log.format_log_notice(log_path)}${verdict}\n`,
163
182
  )
164
183
  }
165
184
 
@@ -183,15 +202,15 @@ function report_gate_steps(
183
202
  header: gate_step_header(result),
184
203
  output: result.output,
185
204
  }))
186
- const failed = results.filter((result) => is_gate_step_failed(result))
187
205
  const written_log = gate_log.write_gate_log(entries, report.log_path)
188
206
 
189
- print_gate_summary(failed, report.elapsed_ms, report.step_count, written_log)
207
+ print_gate_summary(results, report, written_log)
190
208
 
191
- return failed.map((result) => result.label)
209
+ return results.filter((result) => is_gate_step_failed(result)).map((result) => result.label)
192
210
  }
193
211
 
194
212
  const gate_report = {
213
+ format_budget_notes,
195
214
  format_failure_actions,
196
215
  has_checker_warning,
197
216
  is_gate_step_failed,