nx_joblost_gate.nx source
↩ module page · 623 lines · 36156 B
1// nx_joblost_gate.nx -- WHICH ASYNC JOBS DIED, as distinct from which are still running.
2//
3// WHY THIS EXISTS, measured 2026-08-07. Under load (loadavg 18.18 on the 8-thread box,
4// nx_tools_api_se in D-state, /api/health 503 "upstream backend gave no response inside the edge
5// window") nx_tools_api claims a job id with O_CREAT|O_EXCL and can then die before the worker ever
6// produces output. The caller gets the bare JSON object `{}`. Two of my writes were destroyed that
7// way inside five minutes and I only noticed because I happened to read the row back. The estate's
8// standing rule for a dropped response -- VERIFY BY ARTEFACT -- fails silently here, because the
9// artefact was never written and the job record is EMPTY rather than ABSENT.
10//
11// ---------------------------------------------------------------------------------------------
12// v1 OF THIS GATE WAS WRONG AND SHIPPED GREEN. It counted every zero-byte .claim as a LOST job.
13// Reading nx_tools_api afterwards showed the claim is created EMPTY and only filled at completion
14// (ta_job_put -> sys_renameat), so a zero-byte claim is ALSO the normal representation of a job that
15// is still RUNNING. v1 therefore merged "in flight" with "died" under the alarming name.
16// ★★★★★★ I WROTE "UNKNOWN IS ITS OWN BUCKET" INTO THIS FILE'S OWN HEADER AND THEN BROKE IT IN THE
17// SAME HOUR. A LAW YOU CAN RECITE IS NOT A LAW YOU HAVE APPLIED.
18// v1 also declared the imprecision as unavoidable -- "separating in flight from wedged needs a clock
19// and this gate does not have one". IT HAD ONE ALL ALONG: the job id IS the epoch second, printed in
20// every filename.
21// ★★★★★★ THE INFORMATION I DECLARED UNAVAILABLE WAS ENCODED IN THE NAME OF THE FILE I WAS ALREADY
22// READING. "THIS CANNOT BE MEASURED" IS A CLAIM THAT MUST BE EARNED, EXACTLY LIKE ANY OTHER.
23//
24// FIVE BUCKETS, each with a different remedy, none folded into another:
25// DONE state=DONE was written -- healthy, nothing to do
26// INFLIGHT empty claim, no .out, young -- probably running; NOT an alarm
27// LOST empty claim, no .out, OLD -- the id was reserved and nothing ever came back
28// ORPHAN empty claim but .out EXISTS -- the worker produced output and never renamed the claim,
29// so a poller waiting on state=DONE waits forever
30// REAPED a tombstone was APPENDED -- adjudicated and closed by nx_jobclaim_reap; the claim
31// keeps its original marker, so only a reader that tests
32// REAPED FIRST can see it (2026-08-20)
33// The age threshold is DECLARED and argv-overridable, and it is deliberately generous: jobs run with
34// tmo=0 (no timeout) precisely so they can outlive the request window, so a tight bound would accuse
35// healthy long jobs. ★ A HEURISTIC THAT GATES AN ALARM MUST BE WRONG IN THE DIRECTION OF SILENCE.
36//
37// license_tier: ORIGINAL expect_exit: 0
38import "syscalls.nx"
39import "nx_gate_verdict.nx"
40// THE SHARED CONTRACT WITH THE REAPER. jr_state_of, the marker literals and the age threshold live
41// in ONE place so this READER and the WRITER (nx_jobclaim_reap) cannot disagree on the wire.
42import "nx_jobclaim_lib.nx"
43
44const JL_JOBS: *u8 = "_jobs" as *u8
45const JL_RATCHET: *u8 = "knowledge/status/joblost_ratchet.conf" as *u8
46const JL_DEFAULT_FLOOR: i64 = 0
47// THE AGE THRESHOLD IS NO LONGER A CONST HERE. It is read through nx_jobclaim_lib.jr_maxage, which
48// both this census and the reaper call, so the two hold ONE number: the reaper is only sound while
49// its guard is at least as generous as this bar, and sharing the ruler makes that hold by
50// construction instead of by two people remembering the same literal. The code default when the conf
51// is absent is JR_DEFAULT_MAX_AGE_SEC, and it is the same 3600 this line used to carry.
52// WARNING THIS LINE USED TO CARRY: it claimed argv[1] overrides, and main() never read argv at all.
53// A documented override that does not exist is worse than none. It exists now (argv > conf > code
54// default, rule 17) and it refuses any argv[1] that is not a positive integer, because the MCP
55// surface passes a VERB there and a lenient parse would make maxage 0 and report the whole plane LOST.
56const JL_DBUF: i64 = 65536
57const JL_FBUF: i64 = 4096
58const JL_PATH: i64 = 1024
59const JL_MODE: i64 = 420
60
61const JL_TOTAL: i64 = 0
62const JL_DONE: i64 = 1
63const JL_INFLIGHT: i64 = 2
64const JL_LOST: i64 = 3
65const JL_ORPHAN: i64 = 4
66const JL_UNKNOWN: i64 = 5
67// REAPED, added 2026-08-20 with nx_jobclaim_reap. A dead claim is now retired by APPENDING a
68// tombstone rather than by an unlink -- rule 13, the reservation record is the evidence a later
69// investigation needs, and an unlink would destroy it. That makes REAPED a SIXTH state with its own
70// remedy (none: it is closed business), and it gets its own bucket rather than being folded into
71// DONE, which would claim the job finished, or left inside CLAIMED, which would keep counting it
72// LOST forever.
73// A REAPER SHIPPED ALONE IS A NO-OP THAT LOOKS LIKE A FIX. This bucket and the REAPED-first order
74// inside jr_state_of are the READER half of that change; without them the tombstone lands, this
75// count does not move, and the partition tooth breaks.
76const JL_REAPED: i64 = 6
77// The number of COUNTER buckets, so the zeroing loop is derived from the layout instead of carrying
78// a hand-typed 6 that silently stops zeroing the newest bucket. Must stay <= JL_IDBASE or the first
79// LOST id would be overwritten by a counter; T10 asserts exactly that.
80const JL_SLOTS: i64 = 7
81
82// THE WORKLIST TRAVELS WITH THE COUNT. `LOST=4` and nothing else forces the reader to re-derive by hand
83// exactly the scan this organ just performed. The ids live in the SAME `out` block as the counters, past
84// JL_IDBASE, so no call site changes signature -- only the allocation grows, and it is DERIVED from
85// (JL_IDBASE + JL_LOSTCAP) rather than typed as a second copy of its own shape.
86const JL_IDBASE: i64 = 8
87const JL_LOSTCAP: i64 = 32
88
89func jl_slen(p: *u8) -> i64 {
90 var n: i64 = 0
91 while p[n] != (0 as u8) { n = n + 1 }
92 return n
93}
94
95// jl_has WAS HERE and is now jr_has in nx_jobclaim_lib. It was deleted rather than left beside the
96// imported one: two substring searchers in one compilation unit is the duplicate-ruler defect, and a
97// function nothing calls is what nx_unwired counts. The marker LITERALS moved with it, which is the
98// point -- the writer and this reader now take them from the same place.
99
100func jl_ends_claim(nm: *u8, nl: i64) -> i64 {
101 if nl < 7 { return 0 }
102 let suf: *u8 = ".claim" as *u8
103 var k: i64 = 0
104 while k < 6 { if nm[nl-6+k] != suf[k] { return 0 } k = k + 1 }
105 return 1
106}
107
108// "job_<digits>.claim" -> <digits>, or -1 when the name does not carry an id. A name we cannot parse
109// must NOT silently become age 0 (which would read as INFLIGHT and hide it) -- it becomes UNKNOWN.
110func jl_parse_id(nm: *u8, nl: i64) -> i64 {
111 let pre: *u8 = "job_" as *u8
112 if nl < 5 { return 0 - 1 }
113 var k: i64 = 0
114 while k < 4 { if nm[k] != pre[k] { return 0 - 1 } k = k + 1 }
115 var v: i64 = 0
116 var seen: i64 = 0
117 var i: i64 = 4
118 while i < nl {
119 let c: i64 = nm[i] as i64
120 if c >= 48 { if c <= 57 { v = v * 10 + (c - 48); seen = 1; i = i + 1 } else { i = nl } }
121 else { i = nl }
122 }
123 if seen == 0 { return 0 - 1 }
124 return v
125}
126
127func jl_read(path: *u8, buf: *u8, cap: i64) -> i64 {
128 let fd: i64 = sys_openat_rd(path)
129 if fd < 0 { return 0 - 1 }
130 var tot: i64 = 0
131 while tot < cap {
132 let n: i64 = sys_read(fd, (buf as i64 + tot) as *u8, cap - tot)
133 if n <= 0 { break }
134 tot = tot + n
135 }
136 sys_close(fd)
137 return tot
138}
139
140func jl_exists(path: *u8) -> i64 {
141 let fd: i64 = sys_openat_rd(path)
142 if fd < 0 { return 0 }
143 sys_close(fd)
144 return 1
145}
146
147// THE CLASSIFIER, a function over a DIRECTORY PATH so the fixtures below drive THIS code rather than a
148// copy of it -- a tooth that tests a re-implementation proves nothing about what ships.
149// Returns 0 ok, -1 if the directory could not be opened (a precondition failure, never a silent zero).
150func jl_scan(dir: *u8, now: i64, maxage: i64, out: *i64, dbuf: *u8, fbuf: *u8, pbuf: *u8) -> i64 {
151 var z: i64 = 0
152 while z < JL_SLOTS { out[z] = 0; z = z + 1 }
153
154 let dfd: i64 = __syscall(257, 0-100, dir, 0x10000, 0, 0, 0)
155 if dfd < 0 { return 0 - 1 }
156
157 let dl: i64 = jl_slen(dir)
158 var go: i64 = 1
159 // ONE getdents64 CALL IS NOT A DIRECTORY LISTING -- loop until it returns 0, or a big directory is
160 // read as a prefix and its total published as fact.
161 while go == 1 {
162 let nread: i64 = __syscall(217, dfd, dbuf, JL_DBUF, 0, 0, 0)
163 if nread <= 0 { go = 0 } else {
164 var pos: i64 = 0
165 while pos < nread {
166 let reclen: i64 = (dbuf[pos+16] as i64) | ((dbuf[pos+17] as i64) << 8)
167 var nl: i64 = 0
168 while dbuf[pos+19+nl] != (0 as u8) { nl = nl + 1 }
169 let nmp: *u8 = ((dbuf as i64) + pos + 19) as *u8
170 if jl_ends_claim(nmp, nl) == 1 {
171 if dl + nl + 8 < JL_PATH {
172 var c: i64 = 0
173 while c < dl { pbuf[c] = dir[c]; c = c + 1 }
174 pbuf[dl] = 47 as u8
175 c = 0
176 while c < nl { pbuf[dl+1+c] = nmp[c]; c = c + 1 }
177 pbuf[dl+1+nl] = 0 as u8
178
179 out[JL_TOTAL] = out[JL_TOTAL] + 1
180 let n: i64 = jl_read(pbuf, fbuf, JL_FBUF)
181
182 // PENDING = "claimed, not finished", in EITHER representation.
183 // Before 2026-08-07 that was an EMPTY claim; nx_tools_api now writes
184 // `state=CLAIMED ts=<id>` inside the O_EXCL reservation, so the same condition
185 // has two spellings while pre-fix claims remain on disk. Both route to the same
186 // age logic, because the QUESTION is identical: has it been too long?
187 // ★ WHEN A REPRESENTATION CHANGES, MAP THE OLD AND NEW ONTO ONE STATE -- do
188 // not let the new spelling fall through to UNKNOWN, which is exactly what this
189 // gate did for a full deploy cycle after the producer was fixed.
190 // ONE CLASSIFIER, IMPORTED FROM THE LIB THE WRITER ALSO IMPORTS. The order --
191 // REAPED first, then DONE, then CLAIMED -- is jr_state_of contract, and it
192 // lives beside the marker nx_jobclaim_reap writes. It has to: the tombstone is
193 // APPENDED, so a reaped claim STILL contains state=CLAIMED, and a reader that
194 // tested CLAIMED first would report every reaped claim as still pending. The
195 // tombstone would land, this count would not move, and the reaper would read
196 // as a fix while being a no-op. Sharing the classifier makes that ordering
197 // impossible to get right in one organ and wrong in the other.
198 var pending: i64 = 0
199 // n < 0 = the file vanished between listing and reading (a live writer).
200 // A RACE IS NOT A FAILURE -- never fold it into the alarming bucket.
201 if n < 0 { out[JL_UNKNOWN] = out[JL_UNKNOWN] + 1 }
202 else {
203 let st: i64 = jr_state_of(fbuf, n)
204 if st == JR_ST_REAPED { out[JL_REAPED] = out[JL_REAPED] + 1 }
205 else {
206 if st == JR_ST_DONE { out[JL_DONE] = out[JL_DONE] + 1 }
207 else {
208 if st == JR_ST_UNKNOWN { out[JL_UNKNOWN] = out[JL_UNKNOWN] + 1 }
209 else { pending = 1 }
210 }
211 }
212 }
213
214 if pending == 1 {
215 let id: i64 = jl_parse_id(nmp, nl)
216 if id < 0 { out[JL_UNKNOWN] = out[JL_UNKNOWN] + 1 }
217 else {
218 // sibling ".out": swap the 6-char ".claim" tail for ".out"
219 var e: i64 = dl + 1 + nl - 6
220 pbuf[e] = 46 as u8
221 pbuf[e+1] = 111 as u8
222 pbuf[e+2] = 117 as u8
223 pbuf[e+3] = 116 as u8
224 pbuf[e+4] = 0 as u8
225 let hasout: i64 = jl_exists(pbuf)
226 if hasout == 1 { out[JL_ORPHAN] = out[JL_ORPHAN] + 1 }
227 else {
228 if now - id > maxage {
229 // NAME IT, do not merely count it. Store BEFORE the increment so
230 // the slot index is the count-so-far. Overflow past JL_LOSTCAP is
231 // DECLARED by the printer as a prefix, never dropped in silence.
232 let k: i64 = out[JL_LOST]
233 if k < JL_LOSTCAP { out[JL_IDBASE + k] = id }
234 out[JL_LOST] = out[JL_LOST] + 1
235 }
236 else { out[JL_INFLIGHT] = out[JL_INFLIGHT] + 1 }
237 }
238 }
239 }
240 }
241 }
242 if reclen <= 0 { pos = nread } else { pos = pos + reclen }
243 }
244 }
245 }
246 sys_close(dfd)
247 return 0
248}
249
250// Empty a fixture directory at SETUP. ★★★★★ A GATE THAT IS NOT IDEMPOTENT REPORTS ON ITS FIRST RUN
251// AND LIES ABOUT EVERY RUN AFTER. Cleaning belongs in SETUP and not in a teardown, because a teardown
252// does not run when a run crashes -- and the residue then becomes the next run's evidence.
253// MEASURED: v2 of this gate went RED on its first run because v1's fixture files (job_1.claim,
254// job_2.claim) were still sitting in these directories, so `total` read 4 where the teeth expected 2.
255// The gate caught it only because T2/T3 assert the fixture REACHED ITS CONDITION rather than merely
256// asserting the outcome.
257func jl_clean(dir: *u8, dbuf: *u8, pbuf: *u8) -> i64 {
258 let dfd: i64 = __syscall(257, 0-100, dir, 0x10000, 0, 0, 0)
259 if dfd < 0 { return 0 - 1 }
260 let dl: i64 = jl_slen(dir)
261 var removed: i64 = 0
262 var go: i64 = 1
263 while go == 1 {
264 let nread: i64 = __syscall(217, dfd, dbuf, JL_DBUF, 0, 0, 0)
265 if nread <= 0 { go = 0 } else {
266 var pos: i64 = 0
267 while pos < nread {
268 let reclen: i64 = (dbuf[pos+16] as i64) | ((dbuf[pos+17] as i64) << 8)
269 var nl: i64 = 0
270 while dbuf[pos+19+nl] != (0 as u8) { nl = nl + 1 }
271 let nmp: *u8 = ((dbuf as i64) + pos + 19) as *u8
272 var skip: i64 = 0
273 if nl == 1 { if nmp[0] == (46 as u8) { skip = 1 } }
274 if nl == 2 { if nmp[0] == (46 as u8) { if nmp[1] == (46 as u8) { skip = 1 } } }
275 if skip == 0 {
276 if dl + nl + 2 < JL_PATH {
277 var c: i64 = 0
278 while c < dl { pbuf[c] = dir[c]; c = c + 1 }
279 pbuf[dl] = 47 as u8
280 c = 0
281 while c < nl { pbuf[dl+1+c] = nmp[c]; c = c + 1 }
282 pbuf[dl+1+nl] = 0 as u8
283 sys_unlinkat(pbuf)
284 removed = removed + 1
285 }
286 }
287 if reclen <= 0 { pos = nread } else { pos = pos + reclen }
288 }
289 }
290 }
291 sys_close(dfd)
292 return removed
293}
294
295func jl_touch(path: *u8, body: *u8) -> i64 {
296 let fd: i64 = sys_openat_wr(path, JL_MODE)
297 if fd < 0 { return 0 - 1 }
298 let n: i64 = jl_slen(body)
299 if n > 0 { sys_write(fd, body, n) }
300 sys_close(fd)
301 return 0
302}
303
304// Build "<dir>/job_<id>.<ext>" into buf.
305func jl_mkname(buf: *u8, dir: *u8, id: i64, ext: *u8) -> i64 {
306 var p: i64 = 0
307 var i: i64 = 0
308 while dir[i] != (0 as u8) { buf[p] = dir[i]; p = p + 1; i = i + 1 }
309 buf[p] = 47 as u8; p = p + 1
310 let pre: *u8 = "job_" as *u8
311 i = 0
312 while pre[i] != (0 as u8) { buf[p] = pre[i]; p = p + 1; i = i + 1 }
313 // decimal, most significant first
314 var d: i64 = 1
315 var t: i64 = id
316 while t >= 10 { d = d * 10; t = t / 10 }
317 while d > 0 { buf[p] = ((id / d) % 10 + 48) as u8; p = p + 1; d = d / 10 }
318 i = 0
319 while ext[i] != (0 as u8) { buf[p] = ext[i]; p = p + 1; i = i + 1 }
320 buf[p] = 0 as u8
321 return p
322}
323
324// ABSENT IS NOT ZERO: an absent conf yields the declared default and the caller is TOLD which it got.
325func jl_floor(defaulted: *i64, fbuf: *u8, seen: *i64) -> i64 {
326 defaulted[0] = 1
327 let n: i64 = jl_read(JL_RATCHET, fbuf, JL_FBUF)
328 if n <= 0 { return JL_DEFAULT_FLOOR }
329 // ONE decimal parser in this file, not two. jr_num_from carries exactly the semantics this
330 // function used to hold inline (the first digit run on the first line, and a `seen` flag so an
331 // unparseable conf stays DISTINGUISHABLE from a genuine 0). Writing a second copy here for the
332 // shared age conf would have been the duplicate-ruler defect INSIDE one file -- a class this
333 // estate has already measured and paid for.
334 let v: i64 = jr_num_from(fbuf, n, seen)
335 if seen[0] == 0 { return JL_DEFAULT_FLOOR }
336 defaulted[0] = 0
337 return v
338}
339
340func main(argc: i64, argv: *i64) -> i64 {
341 let ctr: *i64 = gv_ctr()
342 gv_head("=== NX-JOBLOST -- which async jobs DIED, as distinct from which are still running ===" as *u8)
343
344 let dbuf: *u8 = sys_mmap(JL_DBUF)
345 let fbuf: *u8 = sys_mmap(JL_FBUF)
346 let pbuf: *u8 = sys_mmap(JL_PATH)
347 let nbuf: *u8 = sys_mmap(JL_PATH)
348 // SIZED FROM THE LAYOUT, NEVER HAND-TYPED: JL_IDBASE counter slots, then JL_LOSTCAP id slots.
349 let outb: i64 = (JL_IDBASE + JL_LOSTCAP) * 8
350 let real: *i64 = sys_mmap(outb) as *i64
351 let bad: *i64 = sys_mmap(outb) as *i64
352 let good: *i64 = sys_mmap(outb) as *i64
353 let dflt: *i64 = sys_mmap(16) as *i64
354
355 let now: i64 = sys_now_realtime_sec()
356 // THE THRESHOLD, RESOLVED THROUGH THE SHARED RULER (nx_jobclaim_lib), so this census and the
357 // reaper that acts on its findings hold ONE number rather than two literals that drift. Priority
358 // is argv > conf > code default (rule 17) and the SOURCE is PRINTED: a bar nobody can see is the
359 // magic-number defect wearing a conf file.
360 // argv[1] is accepted only as a POSITIVE integer. The MCP surface passes a VERB there, and a
361 // lenient parse would turn it into maxage=0 and report the entire plane LOST -- an alarm made
362 // entirely of the reader mis-parsing its own argument.
363 let mflag: *i64 = sys_mmap(JR_SCRATCH) as *i64
364 let mscr: *i64 = sys_mmap(JR_SCRATCH) as *i64
365 let nseen: *i64 = sys_mmap(JR_SCRATCH) as *i64
366 var maxage: i64 = jr_maxage(mflag, mscr)
367 var maxage_src: *u8 = JR_MAXAGE_CONF
368 if mflag[0] == 1 { maxage_src = "the code default (conf absent or unparseable -- stated, not hidden)" as *u8 }
369 if argc > 1 {
370 let av: i64 = jr_atoi_strict(argv[1] as *u8)
371 if av > 0 { maxage = av; maxage_src = "argv[1] (top of the configuration hierarchy)" as *u8 }
372 }
373
374 // ---- FIXTURES built at RUNTIME in /tmp/<gate>/, never sharing the production _jobs plane.
375 // Their ids are computed FROM `now`, so the age axis under test is real rather than hard-coded:
376 // a fixture with a frozen id would silently drift into "old" and the young case would stop existing.
377 sys_mkdir("/tmp/nx_joblost_gate" as *u8, 493)
378 sys_mkdir("/tmp/nx_joblost_gate/bad" as *u8, 493)
379 sys_mkdir("/tmp/nx_joblost_gate/good" as *u8, 493)
380 let wiped_bad: i64 = jl_clean("/tmp/nx_joblost_gate/bad" as *u8, dbuf, pbuf)
381 let wiped_good: i64 = jl_clean("/tmp/nx_joblost_gate/good" as *u8, dbuf, pbuf)
382
383 let old_id: i64 = now - (maxage * 2)
384 let young_id: i64 = now - 5
385
386 jl_mkname(nbuf, "/tmp/nx_joblost_gate/bad" as *u8, old_id, ".claim" as *u8)
387 jl_touch(nbuf, "" as *u8) // OLD + empty + no .out -> LOST
388 jl_mkname(nbuf, "/tmp/nx_joblost_gate/bad" as *u8, now - 7, ".claim" as *u8)
389 jl_touch(nbuf, "state=DONE rc=0 exit=0 bytes=12\n" as *u8) // healthy alongside it
390
391 jl_mkname(nbuf, "/tmp/nx_joblost_gate/good" as *u8, young_id, ".claim" as *u8)
392 jl_touch(nbuf, "" as *u8) // YOUNG + empty -> INFLIGHT
393 jl_mkname(nbuf, "/tmp/nx_joblost_gate/good" as *u8, now - 9, ".claim" as *u8)
394 jl_touch(nbuf, "state=DONE rc=0 exit=0 bytes=12\n" as *u8)
395
396 // The POST-FIX representation, both ages, so the new spelling is proven to reach the same two
397 // answers as the old one. Without these the gate would silently sort every real in-flight job
398 // into UNKNOWN the moment the producer started writing a marker.
399 sys_mkdir("/tmp/nx_joblost_gate/claimed" as *u8, 493)
400 let wiped_cl: i64 = jl_clean("/tmp/nx_joblost_gate/claimed" as *u8, dbuf, pbuf)
401 jl_mkname(nbuf, "/tmp/nx_joblost_gate/claimed" as *u8, old_id, ".claim" as *u8)
402 jl_touch(nbuf, "state=CLAIMED ts=1\n" as *u8) // OLD + CLAIMED -> LOST
403 jl_mkname(nbuf, "/tmp/nx_joblost_gate/claimed" as *u8, young_id, ".claim" as *u8)
404 jl_touch(nbuf, "state=CLAIMED ts=2\n" as *u8) // YOUNG + CLAIMED -> INFLIGHT
405
406 // THE TOMBSTONE FIXTURE, and it is a DISCRIMINATION test rather than a presence test. Both files
407 // are OLD and both carry state=CLAIMED; only one of them also carries the tombstone -- and that
408 // tombstone is written by jr_tombstone, THE REAL WRITER, not by a hand-typed copy of what it
409 // emits, so this drives the actual producer/consumer pair end to end in one process.
410 // If jr_state_of ever tested CLAIMED before REAPED, BOTH files would read LOST and the reaper
411 // would be a silent no-op. If the REAPED test were too greedy, BOTH would read REAPED and the
412 // second file would stop being reported as LOST. Only the correct order gives 1 and 1, so this
413 // pair cannot be passed by either wrong answer.
414 sys_mkdir("/tmp/nx_joblost_gate/reaped" as *u8, MODE_0755)
415 let wiped_rp: i64 = jl_clean("/tmp/nx_joblost_gate/reaped" as *u8, dbuf, pbuf)
416 let tline: *u8 = sys_mmap(JR_LINE)
417 jl_mkname(nbuf, "/tmp/nx_joblost_gate/reaped" as *u8, old_id, ".claim" as *u8)
418 jl_touch(nbuf, "state=CLAIMED ts=1\n" as *u8)
419 let tomb_bytes: i64 = jr_tombstone(nbuf, now, now - old_id, 0, tline, JR_LINE)
420 jl_mkname(nbuf, "/tmp/nx_joblost_gate/reaped" as *u8, old_id - 1, ".claim" as *u8)
421 jl_touch(nbuf, "state=CLAIMED ts=2\n" as *u8)
422
423 let clm: *i64 = sys_mmap(outb) as *i64
424 let rp: *i64 = sys_mmap(outb) as *i64
425 let rc_bad: i64 = jl_scan("/tmp/nx_joblost_gate/bad" as *u8, now, maxage, bad, dbuf, fbuf, pbuf)
426 let rc_good: i64 = jl_scan("/tmp/nx_joblost_gate/good" as *u8, now, maxage, good, dbuf, fbuf, pbuf)
427 let rc_clm: i64 = jl_scan("/tmp/nx_joblost_gate/claimed" as *u8, now, maxage, clm, dbuf, fbuf, pbuf)
428 let rc_rp: i64 = jl_scan("/tmp/nx_joblost_gate/reaped" as *u8, now, maxage, rp, dbuf, fbuf, pbuf)
429 let rc_real: i64 = jl_scan(JL_JOBS, now, maxage, real, dbuf, fbuf, pbuf)
430 let floor: i64 = jl_floor(dflt, fbuf, nseen)
431
432 gv_puts(" age threshold=" as *u8); gv_num(maxage)
433 gv_puts("s from " as *u8); gv_puts(maxage_src)
434 gv_puts(" now=" as *u8); gv_num(now)
435 gv_puts(" fixture residue wiped at setup: bad=" as *u8); gv_num(wiped_bad)
436 gv_puts(" good=" as *u8); gv_num(wiped_good); gv_puts("\n" as *u8)
437 gv_puts(" fixture bad : total=" as *u8); gv_num(bad[JL_TOTAL])
438 gv_puts(" lost=" as *u8); gv_num(bad[JL_LOST])
439 gv_puts(" inflight=" as *u8); gv_num(bad[JL_INFLIGHT]); gv_puts("\n" as *u8)
440 gv_puts(" fixture good: total=" as *u8); gv_num(good[JL_TOTAL])
441 gv_puts(" lost=" as *u8); gv_num(good[JL_LOST])
442 gv_puts(" inflight=" as *u8); gv_num(good[JL_INFLIGHT]); gv_puts("\n" as *u8)
443 gv_puts(" LIVE _jobs : total=" as *u8); gv_num(real[JL_TOTAL])
444 gv_puts(" done=" as *u8); gv_num(real[JL_DONE])
445 gv_puts(" INFLIGHT=" as *u8); gv_num(real[JL_INFLIGHT])
446 gv_puts(" LOST=" as *u8); gv_num(real[JL_LOST])
447 gv_puts(" ORPHAN=" as *u8); gv_num(real[JL_ORPHAN])
448 gv_puts(" REAPED=" as *u8); gv_num(real[JL_REAPED])
449 gv_puts(" unknown=" as *u8); gv_num(real[JL_UNKNOWN]); gv_puts("\n" as *u8)
450 gv_puts(" ratchet floor=" as *u8); gv_num(floor)
451 if dflt[0] == 1 { gv_puts(" (DEFAULTED -- conf absent or unparseable, stated not hidden)" as *u8) }
452 gv_puts("\n\n" as *u8)
453
454 // ---- THE LOST WORKLIST. A count with no worklist is not actionable, and the reason is already in
455 // hand at measure time: each row is a claim file that can be read, reaped or re-run without
456 // re-deriving the scan. The list DECLARES itself a prefix when the cap binds.
457 var shown: i64 = real[JL_LOST]
458 if shown > JL_LOSTCAP { shown = JL_LOSTCAP }
459 if real[JL_LOST] > 0 {
460 gv_puts(" LOST WORKLIST -- each row is _jobs/job_<id>.claim with no .out and no state=DONE:\n" as *u8)
461 var q: i64 = 0
462 while q < shown {
463 gv_puts(" LOST job_" as *u8); gv_num(real[JL_IDBASE + q])
464 gv_puts(" age=" as *u8); gv_num(now - real[JL_IDBASE + q])
465 gv_puts("s over_threshold_by=" as *u8); gv_num(now - real[JL_IDBASE + q] - maxage)
466 gv_puts("s\n" as *u8)
467 q = q + 1
468 }
469 gv_puts(" listed=" as *u8); gv_num(shown); gv_puts(" of " as *u8); gv_num(real[JL_LOST])
470 if shown < real[JL_LOST] { gv_puts(" <== THIS LIST IS A PREFIX OF ITS OWN COUNT" as *u8) }
471 gv_puts("\n\n" as *u8)
472 }
473
474 var can_real: i64 = 0
475 if rc_real == 0 { can_real = 1 }
476 let looked: i64 = gv_need("the live _jobs directory is readable" as *u8, can_real, ctr)
477
478 // ---- T1 THE BITE, and it is the AGE AXIS that is on trial. Both fixtures hold an empty claim with
479 // no .out; ONLY the age differs. A detector that ignored age would fire on both and fail here --
480 // which is exactly what v1 of this gate did.
481 var fired_bad: i64 = 0
482 var fired_good: i64 = 0
483 if bad[JL_LOST] > 0 { fired_bad = 1 }
484 if good[JL_LOST] > 0 { fired_good = 1 }
485 gv_bite("T1 an OLD empty claim is LOST while a YOUNG one is not -- the two differ ONLY in age" as *u8,
486 fired_bad, fired_good, ctr)
487
488 // ---- T2 the young empty claim must land in INFLIGHT, not vanish. A bucket that quietly drops a row
489 // makes the partition lie and the alarm look clean.
490 var t2: i64 = 0
491 if rc_good == 0 { if good[JL_TOTAL] == 2 { if good[JL_INFLIGHT] == 1 { if good[JL_DONE] == 1 { t2 = 1 } } } }
492 gv_check("T2 the young empty claim is classified INFLIGHT (1 inflight + 1 done, nothing dropped)" as *u8, t2, ctr)
493
494 // ---- T3 the bad fixture reached its condition: exactly 1 lost + 1 done, so T1's fire is not the
495 // artefact of a half-read directory. ★ ASSERT THE FIXTURE REACHED THE CONDITION.
496 var t3: i64 = 0
497 if rc_bad == 0 { if bad[JL_TOTAL] == 2 { if bad[JL_LOST] == 1 { if bad[JL_DONE] == 1 { t3 = 1 } } } }
498 gv_check("T3 the bad fixture partitions exactly 1 lost + 1 done (the fire is not a half-read)" as *u8, t3, ctr)
499
500 // ---- T4 PARTITION SUMS over the live plane. A partition is a claim: check the parts add up.
501 var t4: i64 = 0
502 // REAPED joins the sum. A new bucket that is counted but left out of the partition is a leak
503 // that reads as a healthy census, and the whole point of adding a state is that every claim lands
504 // in exactly one of them.
505 let sum: i64 = real[JL_DONE] + real[JL_INFLIGHT] + real[JL_LOST] + real[JL_ORPHAN] + real[JL_REAPED] + real[JL_UNKNOWN]
506 if looked == 1 { if sum == real[JL_TOTAL] { t4 = 1 } }
507 gv_puts(" partition: " as *u8); gv_num(sum); gv_puts(" of " as *u8); gv_num(real[JL_TOTAL]); gv_puts("\n" as *u8)
508 gv_check("T4 done+inflight+lost+orphan+reaped+unknown == total (no row leaks out of the census)" as *u8, t4, ctr)
509
510 var t5: i64 = 0
511 if looked == 1 { if real[JL_TOTAL] > 0 { t5 = 1 } }
512 gv_check("T5 the live _jobs census is non-vacuous (total > 0)" as *u8, t5, ctr)
513
514 // ---- T6 NEG-CONTROL: a DONE claim is never counted lost no matter how old it is. Without this a
515 // detector keyed on age alone would condemn the entire healthy history of the plane.
516 var t6: i64 = 0
517 if bad[JL_DONE] == 1 { if bad[JL_LOST] == 1 { t6 = 1 } }
518 gv_check("T6 neg-control-an-old-DONE-claim-is-never-LOST (age alone must not condemn history)" as *u8, t6, ctr)
519
520 // ---- T6b THE NEW REPRESENTATION reaches the SAME two answers as the old one. Both fixtures carry
521 // `state=CLAIMED` and differ only in age; if the marker fell through to UNKNOWN this reads 0/0.
522 gv_puts(" fixture claimed: total=" as *u8); gv_num(clm[JL_TOTAL])
523 gv_puts(" lost=" as *u8); gv_num(clm[JL_LOST])
524 gv_puts(" inflight=" as *u8); gv_num(clm[JL_INFLIGHT])
525 gv_puts(" unknown=" as *u8); gv_num(clm[JL_UNKNOWN]); gv_puts("\n" as *u8)
526 var t6b: i64 = 0
527 if rc_clm == 0 { if clm[JL_TOTAL] == 2 { if clm[JL_LOST] == 1 { if clm[JL_INFLIGHT] == 1 { if clm[JL_UNKNOWN] == 0 { t6b = 1 } } } } }
528 gv_check("T6b state=CLAIMED maps to the SAME states as an empty claim (1 lost + 1 inflight, 0 unknown)" as *u8, t6b, ctr)
529
530 // ---- T6c THE TOMBSTONE, END TO END AND IN ONE PROCESS. jr_tombstone WROTE the marker and
531 // jr_state_of READ it back, so this tooth exercises the real producer and the real consumer of
532 // the wire rather than a description of them. It is a DISCRIMINATION: two OLD claims, both
533 // carrying state=CLAIMED, and only the tombstoned one may move out of LOST.
534 gv_puts(" fixture reaped: total=" as *u8); gv_num(rp[JL_TOTAL])
535 gv_puts(" reaped=" as *u8); gv_num(rp[JL_REAPED])
536 gv_puts(" lost=" as *u8); gv_num(rp[JL_LOST])
537 gv_puts(" inflight=" as *u8); gv_num(rp[JL_INFLIGHT])
538 gv_puts(" unknown=" as *u8); gv_num(rp[JL_UNKNOWN])
539 gv_puts(" tombstone_bytes=" as *u8); gv_num(tomb_bytes)
540 gv_puts(" residue wiped at setup=" as *u8); gv_num(wiped_rp); gv_puts("\n" as *u8)
541 var t6c: i64 = 0
542 if rc_rp == 0 { if tomb_bytes > 0 { if rp[JL_TOTAL] == 2 { if rp[JL_REAPED] == 1 { if rp[JL_LOST] == 1 { if rp[JL_INFLIGHT] == 0 { if rp[JL_UNKNOWN] == 0 { t6c = 1 } } } } } } }
543 gv_check("T6c a tombstoned OLD claim reads REAPED while its un-tombstoned OLD twin still reads LOST (the REAPED-first order, through the real writer)" as *u8, t6c, ctr)
544
545 // ---- T6d NEG-CONTROL: THE TOMBSTONE IS ADDITIVE. Read the reaped fixture back and assert the
546 // ORIGINAL marker survived. An overwriting reaper would pass T6c perfectly and quietly destroy
547 // the record rule 13 exists to keep -- and no count-based tooth can tell the two apart, because
548 // both produce identical bucket totals.
549 jl_mkname(nbuf, "/tmp/nx_joblost_gate/reaped" as *u8, old_id, ".claim" as *u8)
550 let rpn: i64 = jl_read(nbuf, fbuf, JL_FBUF)
551 var kept_claimed: i64 = 0
552 var kept_reaped: i64 = 0
553 if rpn > 0 {
554 kept_claimed = jr_has(fbuf, rpn, JR_MARK_CLAIMED)
555 kept_reaped = jr_has(fbuf, rpn, JR_MARK_REAPED)
556 }
557 var t6d: i64 = 0
558 if kept_claimed == 1 { if kept_reaped == 1 { t6d = 1 } }
559 gv_puts(" reaped fixture bytes=" as *u8); gv_num(rpn)
560 gv_puts(" still_holds_CLAIMED=" as *u8); gv_num(kept_claimed)
561 gv_puts(" holds_REAPED=" as *u8); gv_num(kept_reaped); gv_puts("\n" as *u8)
562 gv_check("T6d neg-control-the-tombstone-is-ADDITIVE: the reaped claim still holds state=CLAIMED beside state=REAPED" as *u8, t6d, ctr)
563
564 var t7: i64 = 0
565 if looked == 1 { if real[JL_LOST] <= floor { t7 = 1 } }
566 gv_check("T7 lost jobs are at or below the ratchet floor" as *u8, t7, ctr)
567
568 // ---- T8 THE WORKLIST NAMES WHAT IT COUNTS, and it is bound to the BAD FIXTURE where exactly one
569 // claim was planted at a KNOWN id. A count-only implementation fails this by construction, and it
570 // cannot pass on the empty set -- the trap every "no findings" tooth falls into.
571 var t8: i64 = 0
572 if bad[JL_LOST] == 1 { if bad[JL_IDBASE] == old_id { t8 = 1 } }
573 gv_check("T8 the LOST worklist NAMES the id it counted (fixture: the planted old_id is reported back)" as *u8, t8, ctr)
574
575 // ---- T9 the LIVE list accounts for every counted row: either it lists them all, or it is at the
576 // cap AND said so. A silently truncated worklist is a smaller number that reads like better news.
577 var t9: i64 = 0
578 if looked == 1 {
579 if shown == real[JL_LOST] { t9 = 1 }
580 else { if shown == JL_LOSTCAP { t9 = 1 } }
581 }
582 gv_check("T9 the live LOST list accounts for every counted row (all listed, or capped and declared a prefix)" as *u8, t9, ctr)
583
584 // ---- T10 THE LAYOUT INVARIANT, asserted rather than assumed. The counter buckets and the LOST id
585 // list share ONE allocation: counters occupy 0..JL_SLOTS-1 and the ids start at JL_IDBASE. Adding
586 // REAPED took the counters from 6 to 7 against a base of 8. A future eighth state would silently
587 // overwrite the FIRST named LOST id, and the symptom would surface in T8 as a wrong id -- a place
588 // nobody would think to look for a layout bug.
589 var t10: i64 = 0
590 if JL_SLOTS <= JL_IDBASE { t10 = 1 }
591 gv_puts(" layout: counter buckets=" as *u8); gv_num(JL_SLOTS)
592 gv_puts(" id list starts at " as *u8); gv_num(JL_IDBASE)
593 gv_puts(" headroom=" as *u8); gv_num(JL_IDBASE - JL_SLOTS); gv_puts("\n" as *u8)
594 gv_check("T10 the counter buckets fit below JL_IDBASE (a new state must never overwrite the first LOST id)" as *u8, t10, ctr)
595 // ---- WHEN IT IS HONEST TO ADVISE TIGHTENING.
596 // LOST is not a settled quantity while INFLIGHT is non-zero: every in-flight claim can become LOST
597 // by the passage of time alone, with no new failure. Advising a tighter floor in that window would
598 // recommend a bound the very next run breaks by itself.
599 // ★★★★★ A RATCHET ADVISED FROM A METRIC THAT CAN STILL RISE WITHOUT A NEW EVENT WILL ALWAYS
600 // ADVISE AT THE WRONG MOMENT -- WAIT UNTIL THE POPULATION HAS SETTLED.
601 // MEASURED 2026-08-07: the run that first shipped this printed "lower the floor to 0" while a known
602 // dead claim sat 1,300s old under a 3,600s threshold, i.e. it was counselling a floor that would
603 // have gone RED forty minutes later on evidence already present.
604 if real[JL_LOST] < floor {
605 if real[JL_INFLIGHT] == 0 {
606 gv_puts(" RATCHET EARNED: lower the floor in " as *u8); gv_puts(JL_RATCHET)
607 gv_puts(" to " as *u8); gv_num(real[JL_LOST])
608 gv_puts(" -- a ratchet that does not tighten when you improve is just a threshold.\n" as *u8)
609 } else {
610 gv_puts(" ratchet NOT advised yet: lost=" as *u8); gv_num(real[JL_LOST])
611 gv_puts(" is below floor=" as *u8); gv_num(floor)
612 gv_puts(" but " as *u8); gv_num(real[JL_INFLIGHT])
613 gv_puts(" claim(s) are still in flight and can become LOST by age alone.\n" as *u8)
614 }
615 }
616 if real[JL_ORPHAN] > 0 {
617 gv_puts(" NOTE: ORPHAN>0 -- a worker wrote its .out and never renamed the claim, so anything\n" as *u8)
618 gv_puts(" polling for state=DONE on those ids waits forever. Result present, signal missing.\n" as *u8)
619 }
620
621 return gv_verdict("NX-JOBLOST" as *u8, ctr,
622 "an empty claim is RUNNING until it is old; LOST, INFLIGHT and ORPHAN are separate answers with separate remedies" as *u8)
623}