fix(schedulerclient): parse numeric job_status + object-valued pending_reason

Two bugs surfaced by the new scheduler_pend_reasons tool:
1. The apiserver returns job_status as the numeric XCE state bitmask
   (PEND=0x01, RUN=0x02, EXIT=0x04, DONE=0x08), but getJobs read it with
   rowStr (string), which returns "" for a number → every job mapped to URM
   "unknown", so the pending/running status filter matched nothing.
   → JobStatusFromXCEState(int) + rowURMStatus fallback.
2. pending_reason is a JSON object in the row, not a string; rowStr returned
   "" so Extensions["pending_reason"] was empty and the reason never parsed.
   → rowJSON(key) stores the raw JSON value; the MCP pend_reasons parser
   handles the map form.

schedulerclient tests updated (JobStatusFromXCEState table). mcp-server +
schedulerclient test suites pass; live MCP call now returns the bjobs -p style
explanation for a PEND job.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-08-15 03:35:22 +00:00
parent 67abbfa492
commit 53c0083410
3 changed files with 89 additions and 5 deletions

View file

@ -308,8 +308,7 @@ func (hc *HTTPClient) getJobs(req *resty.Request, url string, filter JobFilter)
jobs := make([]*URMJob, 0, len(rows.Rows))
for i := range rows.Rows {
j := rows.Rows[i]
rawStatus := j.rowStr("job_status")
urmStatus := JobStatusFromString(rawStatus)
urmStatus := rowURMStatus(j)
// Apply URM status filter in post-processing
if len(filter.Statuses) > 0 {
@ -358,7 +357,7 @@ func (hc *HTTPClient) getJobs(req *resty.Request, url string, filter JobFilter)
HumanID: j.rowStr("job_id_human"),
Name: j.rowStr("job_name"),
Status: urmStatus,
RawStatus: rawStatus,
RawStatus: rawStatusString(j),
Queue: j.rowStr("queue_name"),
Nodes: j.rowInt("num_hosts"),
Cores: numProcessors,
@ -372,7 +371,7 @@ func (hc *HTTPClient) getJobs(req *resty.Request, url string, filter JobFilter)
Extensions: map[string]any{
"command": j.rowStr("command"),
"project_name": j.rowStr("project_name"),
"pending_reason": j.rowStr("pending_reason"),
"pending_reason": j.rowJSON("pending_reason"),
"resreq": j.rowStr("resreq"),
"exit_status_raw": j.rowStr("exit_status"),
"num_processors": numProcessors,
@ -413,6 +412,40 @@ type apiRow struct {
}
// rowStr extracts a string field. Missing, null, or non-string → "".
// rowURMStatus reads a job's status, preferring the string form (PEND/RUN/...)
// and falling back to the numeric XCE state bitmask the apiserver returns in
// job_status (rowStr can't unmarshal a number into a string).
func rowURMStatus(r apiRow) JobStatus {
if s := r.rowStr("job_status"); s != "" {
return JobStatusFromString(s)
}
return JobStatusFromXCEState(r.rowInt("job_status"))
}
// rawStatusString returns the backend-native status value (string form when
// present, else the numeric state), used for URMJob.RawStatus.
func rawStatusString(r apiRow) string {
if s := r.rowStr("job_status"); s != "" {
return s
}
return strconv.Itoa(r.rowInt("job_status"))
}
// rowJSON extracts a raw JSON field as a generic value (map/string/...).
// Missing or null → nil. Used for object-valued fields like pending_reason
// that rowStr can't read (a JSON object does not unmarshal into a string).
func (r apiRow) rowJSON(key string) any {
raw, ok := r.Data[key]
if !ok || string(raw) == "null" {
return nil
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return nil
}
return v
}
func (r apiRow) rowStr(key string) string {
raw, ok := r.Data[key]
if !ok || string(raw) == "null" {

View file

@ -27,6 +27,28 @@ func JobStatusFromString(s string) JobStatus {
}
}
// JobStatusFromXCEState maps the raw numeric XCE job-state bitmask the
// apiserver returns in job_status (JOB_STAT_PEND=0x01, RUN=0x02, EXIT=0x04,
// DONE=0x08, PSUSP=0x10, USUSP=0x20, SSUSP=0x40 — src/include/xce/types.h) to
// a URM JobStatus. Composite states (e.g. RUN|DONE during cleanup) resolve to
// the most significant, non-PEND state; otherwise fall back to unknown.
func JobStatusFromXCEState(state int) JobStatus {
switch state & 0x7F {
case 0x02, 0x0A: // RUN, RUN|DONE
return JobStatusRunning
case 0x04, 0x0C: // EXIT, EXIT|DONE
return JobStatusFailed
case 0x08: // DONE
return JobStatusSucceeded
case 0x10, 0x20, 0x40: // PSUSP/USUSP/SSUSP
return JobStatusSuspended
case 0x01: // PEND
return JobStatusPending
default:
return JobStatusUnknown
}
}
// ── QueueStatus mapping ─────────────────────────────────────────────────────
// QueueStatusFromString maps the apiserver's composite queue status string

View file

@ -1,6 +1,9 @@
package schedulerclient
import "testing"
import (
"fmt"
"testing"
)
// ── JobStatus mapping tests ─────────────────────────────────────────────────
@ -39,6 +42,32 @@ func TestJobStatusFromString(t *testing.T) {
}
}
func TestJobStatusFromXCEState(t *testing.T) {
tests := []struct {
input int
want JobStatus
}{
{0x01, JobStatusPending}, // JOB_STAT_PEND
{0x02, JobStatusRunning}, // JOB_STAT_RUN
{0x0A, JobStatusRunning}, // RUN|DONE during cleanup
{0x04, JobStatusFailed}, // JOB_STAT_EXIT
{0x08, JobStatusSucceeded}, // JOB_STAT_DONE
{0x10, JobStatusSuspended}, // JOB_STAT_PSUSP
{0x20, JobStatusSuspended}, // JOB_STAT_USUSP
{0x40, JobStatusSuspended}, // JOB_STAT_SSUSP
{0x00, JobStatusUnknown},
{0x80, JobStatusUnknown}, // out of the 7-bit state mask
}
for _, tt := range tests {
t.Run(fmt.Sprintf("0x%02x", tt.input), func(t *testing.T) {
got := JobStatusFromXCEState(tt.input)
if got != tt.want {
t.Errorf("JobStatusFromXCEState(0x%02x) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
// ── QueueStatus mapping tests ────────────────────────────────────────────────
func TestQueueStatusFromString(t *testing.T) {