Task 7 Review — Job API + Manager + server wiring (KEYSTONE)
Reviewed at HEAD d4ec67d. go build ./..., go vet ./..., go test -race -count=1 ./... all green (8 packages, 0 failures).
Spec Compliance — ⚠️
The single-job path (submit → claim → admit → adapter → terminal → audit) is implemented correctly and the seven carried obligations are structurally wired. But there are real defects: an audit/metrics attribution bug (3 of 4 early-failure codes lose their upstream label), a broken truncation-hash invariant on OCR result bodies, a missing panic-recovery boundary around the only new "arbitrary code runs in a bare background goroutine" surface in the codebase, and a real test-coverage gap at exactly the integration seam (server.EnableJobs) the plan called out as required ("drain returns 503 for new submits" is an explicit Task-7 test requirement that was never written).
Obligations A–G
- A. On-prem in job path — satisfied.
internal/jobs/api.go:75-78(Submit) andinternal/jobs/manager.go:246-249(process) both applyup.Class == "external_dev" && !cfg.AllowExternalUpstreamsindependently ofproxy/handler.go.TestAPISubmitExternalUpstreamForbiddenandTestManagerOnPremForbiddenInJobPathboth pass. - B. Idempotent dedup — satisfied.
api.go:80-84400s on missing key;LocalStore.Create(store.go:78-107) andRedisStore.Create(SETNX-based,redisstore.go:181-) dedup atomically under their own lock/atomicity, so a secondCreatenever inserts a second queued record —ClaimNexttherefore can only ever pick up one run.TestAPISubmitIdempotentReturnsSameJob,TestRedisCreateDedupAtomicpass. Verified structurally, not just by the (weak) job_id-equality test. - C. Drain 503 — satisfied for the guard wrapper itself (
server.go:179-191reusess.enter()/leave(), identical pattern to the/v1/guard), but untested: no test anywhere callsEnableJobs(grep confirms zero references outsideserver.go/main.go), so the actualPOST /jobs→ 503+Retry-After-while-draining behavior has never been exercised end-to-end. See Issues. - D. ClaimNext priority loop — satisfied.
claimOrder = [interactive, system, batch](manager.go:25), swept inclaim()(manager.go:203-210), matchingpool.rankand reconciling LocalStore's advisory class arg with RedisStore's genuine per-class lists (per Task 2/3 carry note). - E. ClaimNext no-error backoff — satisfied.
worker()(manager.go:171-199) backs offIdlePoll(default 500ms) on any nil claim without distinguishing "empty" from "store down," per the Task 3 carry note; no busy-spin. - F. Slot-lease release on every exit path — mostly satisfied, with one real gap. All paths in
process()aftertk := p.Enqueue(...)are covered bydefer rel()(releaseOnce) registered immediately after admission (manager.go:282-283), including payload-missing, adapter success/failure/retry-exhaustion, and ctx-cancel/drain (whichreturns early but still runs the defer). The slot-wait-abandon path (SlotWaitTimeout / ctx-done / stopClaim while waiting) usesabandon(tk)(Cancel + drain-race Release) without a slot ever having been "acquired," so it's a different, also-correct code path. Not covered: an unrecovered panic insideadapter.Run— see Issues (Critical). Not tested: retryable-exhausted as a distinct scenario, and the slot-wait-abandon/drain scenario the last fix commit (d4ec67d) specifically targets — see Issues (Important). - G. report/store mutex non-reentrancy — satisfied.
report(manager.go:295-300) only setsj.Stage/j.Progressinside theUpdateStatusmutation closure, never re-entering the store.TestManagerReportNoDeadlockexercises 3 sequential report calls and passes under-race.
Strengths
- The on-prem check, idempotency, and slot-admission/release patterns are faithfully reused from P0 rather than reinvented (
abandon/releaseOncemirrorproxy.cancelOrReleasealmost verbatim, with a code comment cross-referencing it). Manager.Stopgrace-then-hard-cancel semantics are correct and race-clean: workers stop claiming (stopClaim) but finish in-flight work; only a grace-timeout triggersm.cancel(), which is what unblocks a truly-stuck adapter and correctly leaves the job inprocessingforReclaimStalerather than marking it failed (manager.go:328-330).- The
d4ec67dfix (adding<-m.stopClaimto the slot-waitselect) is the right fix for the right bug — without it,Stop()would hang untilSlotWaitTimeout(default 30m) for any worker parked on a saturated pool. readCapped/bounded-read discipline is applied consistently across both adapters and carried through to the manager's own audit-body compress step.- Redis
UpdateStatus's WATCH/MULTI optimistic retry avoids the store-mutex-reentrancy hazard by construction (Go closure runs outside any Redis-side lock), independently of the LocalStore mutex argument in obligation G.
Issues
Critical
- No panic recovery around adapter execution — a single malformed/buggy adapter response can crash the entire gateway process, not just fail one job.
internal/jobs/manager.go:171-199(worker) andmanager.go:238-352(process, which callsadapter.Runatmanager.go:321) run in bare goroutines spawned byStart()(manager.go:118-127); there is norecover()anywhere ininternal/jobs/(confirmed by repo-wide grep — zero hits) or ininternal/server/. This differs materially from the pre-existing P0 request path, where Go'snet/httpserver recovers a panicking handler per-connection and keeps serving; the job manager's worker/reclaim/heartbeat goroutines get no such safety net; an unrecovered panic anywhere in the call chain terminates the whole OS process (all LLM traffic + all other jobs + all replicas' worth of local capacity, not just the offending job), which is strictly worse than every other terminal state task 7 was built to guard against (the "historically worst bug" this task explicitly asks about). Current adapter code (adapter.go,adapter_azuredi.go,adapter_classify.go) doesn't have an obvious panic today, but this is exactly the kind of regression Task 8's fan-out work (arbitrary per-child JSON/merge logic) is likely to introduce, and OCR upstreams are the least-trusted response shape in the whole system (attacker- or bug-controlled JSON/multipart, unlike the vLLM proxy's narrower response shapes).
- Fix: wrap the body ofworker()'s per-job dispatch (orprocess()itself) in adefer func() { if r := recover(); r != nil { ... mark job failed with ADAPTER_PANIC, release slot via the already-deferred rel(), emit audit, log ... } }(). Sincerel()/hbCancel()are alreadydefer-registered insideprocess(), adding a top-level recover inprocess()(or inworker()wrapping them.process(job)call) is sufficient — the existing defers will still fire during the panic unwind before the recover stops propagation.
Important
- Audit/metrics upstream attribution lost for 3 of 4 early-failure codes.
manager.go:241,247,252,287all callm.finish(job, StatusFailed, nil, CODE, ...), andfinish(manager.go:386-388) unconditionally forwardsup=niltofinishWithPayload. ForUNKNOWN_MODEL(line 241)upis genuinely unresolved sonilis correct — but forEXTERNAL_UPSTREAM_FORBIDDEN(line 247),NO_POOL(line 252), andPAYLOAD_MISSING(line 287), a valid non-nilupis already in scope at the call site and is simply not threaded through. The result: the audit event'sUpstreamfield is empty andrecord()(thegateway_jobs_totalmetric) buckets these failures underupstream=""instead of the actual upstream id — exactly the observability the plan's "one audit event per job... operation from upstream" bullet exists to guarantee, and precisely the failure mode (a misconfigured/forbidden OCR upstream) an operator would most want attributed correctly. Not caught byTestManagerOnPremForbiddenInJobPath, which only assertsjob.ErrorCode, never the audit event'sUpstreamfield.
- Fix: change these three call sites to callm.finishWithPayload(job, up, StatusFailed, nil, CODE, queueMs, upstreamMs, nil)directly instead of going throughm.finish, leavingm.finish's nil-up behavior only for the trueUNKNOWN_MODELcase. - Truncated OCR/DI result bodies lose their verification hash.
manager.go:426-430:
go if len(result) > 0 { z, tr, _ := m.compress(result) ev.ResponseBodyZst = z ev.BodyTruncated = ev.BodyTruncated || tr }
discards the response'sfullHashoutright (_). Contrast withproxy/handler.go:256-258which doesif fh != "" { ev.FullBodySHA256 = fh }— i.e., the existing P0 code does propagate the response-side hash when truncated. For OCR/Azure-DI this is the more likely truncation direction (ananalyzeResultwith per-page word/line boxes can dwarfbody_cap_byteseven when the submitted document itself is well under the cap), so this is not a corner case — it's the common one for large multi-page documents. Net effect:body_truncated=truewithfull_body_sha256="", breaking CONVENTIONS' "truncated +full_body_sha256" pairing for exactly the direction (response) most likely to trigger truncation on an OCR job.
- Fix: mirror proxy exactly:if tr && ev.FullBodySHA256 == "" { ev.FullBodySHA256 = fh }(or a dedicated field if request and response can both be truncated and both hashes matter — this same single-field ambiguity exists inproxy/handler.gotoday, so at minimum match its partial fix rather than dropping it entirely). - Zero test coverage of the actual
server.EnableJobsintegration, despite the plan's Task 7 line explicitly requiring "drain returns 503 for new submits" and "submit→poll→completed against a stub adapter" as tests.grep -rln EnableJobs internal/ cmd/returns onlyserver.goandmain.go— no test file calls it.internal/jobs/manager_test.goandinternal/jobs/api_test.goeach construct their own isolatedManager/APIover independentLocalStores and never exercise the guard wrapper atserver.go:179-191, the shared-store wiring betweenAPI.Storeand theManager(server.go:166-172), orShutdown'sjobMgr.Stop(ctx)call (server.go:229-231). The one thing this leaves genuinely unverified is exactly obligation C end-to-end: does a real HTTPPOST /jobsactually get503 + Retry-Afterwhile draining through the real mux? Code inspection says yes, but the specific test the plan asked for doesn't exist.
- Fix: add aserver-package test mirroringTestDrainRejectsNewFinishesInflightbut callings.EnableJobs(jobs.NewLocal(), jobs.ManagerConfig{})first and posting to/jobs; and one submit→poll→completed HTTP-level test using a stub upstream, matching what the isolated jobs-package tests already do at the unit level. - No regression test for the exact scenario the last fix commit (
d4ec67d) addresses. The slot-wait-abandon-on-drain path (manager.go:270-274, the<-m.stopClaimcase added in that commit) has no test that saturates a 1-slot pool, enqueues a second job, callsStop()while it's parked in thetk.Ready()select, and asserts (a)Stop()returns promptly rather than blocking ~SlotWaitTimeout, and (b) the pool'sActive()/Depth()return to a clean state and the job is leftqueued/processing-for-reclaim rather than incorrectlyfailed. Given this is described in the ledger as "the historically worst bug" area and this exact code was touched by a fix committed minutes before this review, it's the single highest-value missing test in the diff. TestManagerReleasesSlotOnEveryTerminalStatedoesn't cover "retryable-exhausted" as its own case, onlysuccess,non-retryable-error, andprocessing-timeout. Structurally the retryable-exhausted path re-joins the sameif runErr != nilbranch as non-retryable, so this is lower risk than #5, but it's one of the nine terminal kinds this review was asked to hand-trace and it's silently assumed rather than asserted.
Minor
queue_msis inflated after a reclaim bounce.ClaimNext(store.go:127-146) setsStartedAt = nowon every claim, including a re-claim afterReclaimStalerequeues a dead worker's job.manager.go:256-259computesqueueMs = StartedAt.Sub(CreatedAt), so for a job that died mid-processing and was later reclaimed,queue_msends up spanning the original queued time plus the entire dead-processing/visibility-timeout window, not the job's actual "time spent queued" — a metrics-accuracy nit, not a functional bug.- Heartbeat goroutine isn't tracked by
Manager.wg.manager.go:303-305doesgo m.heartbeat(hbCtx, job.ID)outside ofm.wg, soStop()'sm.wg.Wait()(manager.go:136-137) can return before every in-flight heartbeat goroutine has observedhbCancel()and returned. In practice this window is a single scheduler tick (the goroutine'sselectsees the closedctx.Done()essentially immediately), and the process exits shortly afterStop()returns inmain.goanyway, so this is benign today — but it meansManager.Stop()doesn't give a hard "zero goroutines" guarantee, which could bite a futuregoleak-style test (the module already vendorsgo.uber.org/goleaktransitively pergo.sum, though nothing currently imports it). cmd/gateway/main.go:92-93callssrv.Shutdown(dctx)thenhs.Shutdown(dctx)sequentially against the same deadline context. If job-manager draining (jobMgr.Stopinsidesrv.Shutdown) consumes the whole 30s grace budget,hs.Shutdownis invoked with an already-expired context;http.Server.Shutdownstill closes listeners immediately in that case, so this is not a hang, but it means the final "wait for idle HTTP conns to close" step gets effectively zero budget instead of its own share. Low practical impact, worth a one-line comment or splitting the budget if it ever becomes a real drain-time constraint.
Assessment
Task quality: Needs fixes before merge, not because the design is wrong — the architecture (Manager over Store over Pool, single shared audit/emit closure, guard-wrapper reuse) is sound and matches P0's patterns exactly as instructed — but because:
- Issue #1 (no panic recovery in the job worker goroutines) is a genuine Critical: it converts what should be a per-job failure into a whole-gateway outage, which is a strictly worse failure mode than anything this task's obligations were written to prevent, and it's a cheap, mechanical fix (one
recover()wrappingprocess()). - Issues #2 and #3 are real, demonstrable defects in the audit trail this whole task exists to produce correctly (attribution loss + broken truncation-hash invariant on the response side), not stylistic nits.
- Issue #4 is a direct, named gap against the plan's own Task 7 test list ("drain returns 503 for new submits") — the behavior is very likely correct by inspection, but "very likely correct by inspection" is exactly the standard this plan explicitly rejected for the slot-lease area, and the same untested seam is where the Store/Manager/API three-way wiring actually gets exercised together for the first time.
None of these require a redesign — recommend: add the recover() boundary, thread up through the three finish-with-up-in-scope call sites, fix the response-hash drop to match proxy.compress's pattern, and add the two missing tests (server-level EnableJobs drain/submit-poll test, and a slot-wait-abandon-on-drain regression test) — then re-review should be quick since nothing here touches the core admission/claim/store logic that Tasks 1-6 already validated.