@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.
- package/.claude/skills/workflow-commands/backlogrun-lanes.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -2
- package/.claude/skills/workflow-commands/followup.md +5 -0
- package/.claude/skills/workflow-commands/kickoff.md +1 -0
- package/.claude/skills/workflow-commands/latest-gate.md +1 -1
- package/.github/workflows/publish.yml +2 -0
- package/README.md +30 -31
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-JUDKDCDP.js → chunk-4TZVB44A.js} +8 -8
- package/dist/hooks/{chunk-RNWHN5BP.js → chunk-F3ELQ2AF.js} +3 -3
- package/dist/hooks/{chunk-VC3PZPZO.js → chunk-GPTFMK5E.js} +1 -1
- package/dist/hooks/{chunk-FIVDHQGT.js → chunk-KHGB5S3Y.js} +1 -1
- package/dist/hooks/{chunk-NV2JRZZO.js → chunk-KHX4U3BG.js} +17 -2
- package/dist/hooks/{chunk-F56MFS4B.js → chunk-KRBERBBI.js} +3 -3
- package/dist/hooks/{chunk-TQNPDZNH.js → chunk-QVSWU24O.js} +4 -4
- package/dist/hooks/{chunk-55HHG5PX.js → chunk-WK2XSHSI.js} +284 -63
- package/dist/hooks/{chunk-5VMKCCC4.js → chunk-XZYKEM6J.js} +2 -2
- package/dist/hooks/{chunk-6HJJXRBX.js → chunk-ZQGD4GBY.js} +1 -1
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/inputs.json +172 -170
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +3 -17
- package/dist/josh.js +279 -63
- package/package.json +1 -1
- package/prompts/collaboration-workflow/issue-template.md +1 -0
- package/scripts/gate/core-admission.ts +162 -0
- package/scripts/gate/core-budget.ts +121 -67
- package/scripts/gate/gate-plan.ts +35 -2
- package/scripts/gate/gate-report.ts +28 -9
- package/scripts/gate/machine-capacity.ts +175 -0
- package/scripts/gate/verification-gate.ts +7 -2
- package/scripts/init/init.ts +5 -1
- package/scripts/init/package-manager-pin.ts +35 -0
- package/scripts/issue/issue-auto-ok.ts +31 -6
- package/scripts/issue/issue-file-cli.ts +31 -8
- package/scripts/issue/issue-file.ts +5 -0
- package/scripts/issue/issue-labels.ts +13 -0
- package/scripts/issue/issue-release-cli.ts +155 -0
- package/scripts/issue/issue-release.ts +58 -0
- package/scripts/josh/josh-command-types.ts +3 -0
- package/scripts/josh/josh-commands-development.ts +8 -0
- package/scripts/josh/josh-commands-issue.ts +8 -1
- package/scripts/josh/josh-commands-lane.ts +2 -1
- package/scripts/josh/josh-commands-maintenance.ts +2 -2
- package/scripts/josh/josh-logic.ts +4 -3
- package/scripts/lane/lane-cli.ts +9 -1
- package/scripts/lane/lane-leftover.ts +43 -29
- package/scripts/lane/lane-open.ts +5 -5
- package/scripts/lane/lane-paths.ts +13 -0
- package/scripts/lane/lane-registry.ts +11 -0
- package/scripts/lane/lane-stray.ts +140 -0
- package/scripts/release/github-release-environment.ts +1 -0
- package/scripts/release/github-release-runs.ts +115 -0
- package/scripts/release/github-release.ts +13 -67
- package/scripts/run/cut/run-cut-cli.ts +16 -3
- package/scripts/run/event/run-event-render.ts +1 -0
- package/scripts/run/event/run-event-stream.ts +4 -0
- package/scripts/run/run-step.ts +5 -2
- package/scripts/run/wake/run-wake-cli.ts +2 -3
- package/scripts/run/wake/run-wake-describe.ts +56 -5
- package/scripts/run/wake/run-wake-loop.ts +5 -13
- package/scripts/run/wake/run-wake.ts +15 -64
- package/scripts/version/latest-corepack.ts +109 -78
- 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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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(
|
|
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
|
-
|
|
218
|
-
|
|
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
|
|
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<
|
|
248
|
-
|
|
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,
|
|
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 +
|
|
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
|
-
{
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
step_count: string,
|
|
168
|
+
results: ReadonlyArray<GateStepResult>,
|
|
169
|
+
report: GateReport,
|
|
153
170
|
log_path?: string,
|
|
154
171
|
): void {
|
|
155
|
-
const
|
|
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(
|
|
207
|
+
print_gate_summary(results, report, written_log)
|
|
190
208
|
|
|
191
|
-
return
|
|
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,
|