code wiki / (root) / nx_joblost_gate.nx

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}