feat(mcp-server): scheduler_pend_reasons tool — bjobs -p equivalent

New read-only tool that answers "why is my job waiting": lists PEND jobs (or
one by id) and translates each job's pending_reason into a human-readable
scenario (LSF bjobs -p style). Maps the PREASON_* codes surfaced by the
PEND-reason work (4e2ac86): no_resource / match_fail / limit_global /
limit_host / queue_closed / queue_draining / time_window / host_unavail.

- internal/pendreason.go: pending_reason JSON parsing (mirrors apiserver
  rawPendReason) + reasonScenario() translation + bjobs -p style summary.
- internal/app.go: HandlePendReasons — id param for one job, else pending
  list filtered by queue/owner/limit.
- cmd/main.go: register scheduler_pend_reasons (read-only).
- 5 unit tests (known/unknown codes, parse from JSON, missing reason,
  summary format). go test ./... + go vet clean.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-08-15 03:25:28 +00:00
parent 0e9609af6d
commit 67abbfa492
4 changed files with 264 additions and 0 deletions

View file

@ -159,6 +159,17 @@ func main() {
mcp.WithDestructiveHintAnnotation(false),
), app.HandleEstimateWaitTime)
// ── scheduler_pend_reasons (bjobs -p equivalent) ──
s.AddTool(mcp.NewTool("scheduler_pend_reasons",
mcp.WithDescription("Explain why PEND (queued) jobs are waiting — the bjobs -p equivalent. Lists pending jobs (or one by id) and translates each job's PEND reason into a human-readable scenario (no_resource, match_fail, limit_host/global, queue_closed, host_unavail, ...)."),
mcp.WithString("id", mcp.Description("Specific job id to explain (optional; defaults to all pending jobs)")),
mcp.WithString("queue", mcp.Description("Filter by queue name")),
mcp.WithString("owner", mcp.Description("Filter by job owner username")),
mcp.WithNumber("limit", mcp.Description("Max results to return")),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
), app.HandlePendReasons)
// ── write tools (Phase 1) ──
// Write tools are annotated destructive so the MCP client (e.g. Claude
// Code) prompts the user for confirmation before executing them — the

View file

@ -113,6 +113,64 @@ func (a *App) HandleGetJob(ctx context.Context, req mcp.CallToolRequest) (*mcp.C
return textResult(job)
}
// HandlePendReasons implements the scheduler_pend_reasons tool — the
// bjobs -p equivalent. Lists PEND jobs (or one job by id) and translates each
// job's pending_reason into a human-readable "why is my job waiting".
func (a *App) HandlePendReasons(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if id := getString(req, "id"); id != "" {
job, err := a.client.GetJob(ctx, id)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("get job failed: %v", err)), nil
}
return textResult(formatPendJob(job))
}
filter := sclient.JobFilter{
Statuses: []sclient.JobStatus{sclient.JobStatusPending},
Queue: getString(req, "queue"),
Owner: getString(req, "owner"),
}
if n := getNumber(req, "limit"); n > 0 {
filter.Limit = int(n)
}
jobs, _, err := a.client.ListJobs(ctx, filter)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("list pending jobs failed: %v", err)), nil
}
out := make([]any, 0, len(jobs))
for _, j := range jobs {
out = append(out, formatPendJob(j))
}
resp := map[string]any{"jobs": out}
if len(out) == 0 {
resp["note"] = "no pending jobs"
}
return textResult(resp)
}
// formatPendJob renders one job's PEND reason, or notes it is not waiting.
func formatPendJob(job *sclient.URMJob) map[string]any {
pr := parsePendingReason(*job)
if pr == nil {
return map[string]any{
"id": job.HumanID,
"name": job.Name,
"queue": job.Queue,
"status": job.Status,
"explanation": "no PEND reason reported (job is not waiting or reason not attached)",
}
}
return map[string]any{
"id": job.HumanID,
"name": job.Name,
"queue": job.Queue,
"status": job.Status,
"main_reason": pr.MainReasonName,
"reason": pr,
"explanation": pendReasonSummary(pr),
}
}
func (a *App) HandleListQueues(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
queues, err := a.client.ListQueues(ctx)
if err != nil {

View file

@ -0,0 +1,122 @@
// PEND reason translation — the scheduler-agent's "bjobs -p" equivalent.
//
// The apiserver attaches a pending_reason JSONB to each PEND job
// ({main_reason, main_reason_name, host_reasons[], res_reasons[]}, see
// apiserver/backend/convert_pure.go). schedulerclient surfaces it as the
// JSON-encoded string Extensions["pending_reason"]. This module parses it and
// translates each PREASON_* code into the human-readable scenario LSF bjobs -p
// would print, so the agent can answer "why is my job waiting".
package internal
import (
"encoding/json"
"fmt"
"strings"
sclient "schedulerclient"
)
// pendingReason mirrors the apiserver rawPendReason (convert_pure.go).
type pendingReason struct {
MainReason int `json:"main_reason"`
MainReasonName string `json:"main_reason_name"`
HostReasons []hostReason `json:"host_reasons,omitempty"`
ResReasons []resReason `json:"res_reasons,omitempty"`
}
type hostReason struct {
Host string `json:"host"`
Reason int `json:"reason"`
ReasonName string `json:"reason_name"`
}
type resReason struct {
Resource string `json:"resource"`
Host string `json:"host"`
Reason int `json:"reason"`
ReasonName string `json:"reason_name"`
}
// reasonScenario maps a PREASON_* name to the human-readable why. Unknown
// names are reported verbatim so a new scheduler reason never silently reads
// as something else.
func reasonScenario(name string) string {
switch name {
case "bad_resreq":
return "the resource request is invalid or cannot be satisfied"
case "no_resource":
return "insufficient resource capacity"
case "match_fail":
return "no host matches the job's host-selection condition"
case "limit_global":
return "a global quota is exhausted"
case "limit_host":
return "a host-level quota is exhausted"
case "queue_closed":
return "the queue is closed (not accepting new jobs)"
case "queue_draining":
return "the queue is draining (no new jobs until it empties)"
case "time_window":
return "outside the queue's dispatch window"
case "host_unavail":
return "the host is unavailable"
default:
return fmt.Sprintf("unknown reason (%s)", name)
}
}
// parsePendingReason reads the pending_reason JSON string off a job's
// Extensions. Returns nil when the job has no reason (e.g. not PEND).
func parsePendingReason(job sclient.URMJob) *pendingReason {
raw, ok := job.Extensions["pending_reason"]
if !ok {
return nil
}
var pr pendingReason
switch v := raw.(type) {
case string:
if v == "" {
return nil
}
if err := json.Unmarshal([]byte(v), &pr); err != nil {
return nil
}
case map[string]any:
b, _ := json.Marshal(v)
if err := json.Unmarshal(b, &pr); err != nil {
return nil
}
default:
return nil
}
return &pr
}
// pendReasonSummary builds a bjobs -p style one-liner for one job, e.g.
//
// waiting: no host matches the job's host-selection condition
// host t1: match_fail (no host matches the job's host-selection condition)
// resource slots on t2: no_resource (insufficient resource capacity)
//
// The main reason is preferred when present; otherwise the first detail row.
func pendReasonSummary(pr *pendingReason) string {
if pr == nil {
return "no PEND reason reported"
}
var lines []string
main := reasonScenario(pr.MainReasonName)
lines = append(lines, fmt.Sprintf("waiting: %s", main))
for _, h := range pr.HostReasons {
lines = append(lines, fmt.Sprintf(" host %s: %s (%s)", h.Host, h.ReasonName,
reasonScenario(h.ReasonName)))
}
for _, r := range pr.ResReasons {
hostPart := ""
if r.Host != "" {
hostPart = " on " + r.Host
}
lines = append(lines, fmt.Sprintf(" resource %s%s: %s (%s)", r.Resource, hostPart,
r.ReasonName, reasonScenario(r.ReasonName)))
}
return strings.Join(lines, "\n")
}

View file

@ -0,0 +1,73 @@
package internal
import (
"strings"
"testing"
sclient "schedulerclient"
)
func TestReasonScenarioKnownCodes(t *testing.T) {
cases := map[string]string{
"bad_resreq": "the resource request is invalid",
"no_resource": "insufficient resource capacity",
"match_fail": "no host matches",
"limit_global": "global quota",
"limit_host": "host-level quota",
"queue_closed": "queue is closed",
"queue_draining": "queue is draining",
"time_window": "dispatch window",
"host_unavail": "host is unavailable",
}
for name, wantSub := range cases {
got := reasonScenario(name)
if !strings.Contains(got, wantSub) {
t.Errorf("reasonScenario(%q) = %q, want substring %q", name, got, wantSub)
}
}
}
func TestReasonScenarioUnknownVerbose(t *testing.T) {
if got := reasonScenario("some_new_code"); !strings.Contains(got, "some_new_code") {
t.Errorf("unknown reason name should be reported verbatim, got %q", got)
}
}
func TestParsePendingReason(t *testing.T) {
job := sclient.URMJob{Extensions: map[string]any{
"pending_reason": `{"main_reason":5,"main_reason_name":"limit_host","host_reasons":[],"res_reasons":[{"resource":"hg1_slots_per_host","host":"t1","reason":5,"reason_name":"limit_host"}]}`,
}}
pr := parsePendingReason(job)
if pr == nil {
t.Fatal("expected a parsed pending reason")
}
if pr.MainReasonName != "limit_host" {
t.Errorf("main_reason_name = %q, want limit_host", pr.MainReasonName)
}
if len(pr.ResReasons) != 1 || pr.ResReasons[0].Resource != "hg1_slots_per_host" {
t.Errorf("res_reasons = %+v, want one hg1_slots_per_host", pr.ResReasons)
}
}
func TestParsePendingReasonMissing(t *testing.T) {
if pr := parsePendingReason(sclient.URMJob{}); pr != nil {
t.Errorf("job without pending_reason should parse to nil, got %+v", pr)
}
if pr := parsePendingReason(sclient.URMJob{Extensions: map[string]any{"pending_reason": ""}}); pr != nil {
t.Errorf("empty pending_reason should parse to nil, got %+v", pr)
}
}
func TestPendReasonSummary(t *testing.T) {
pr := &pendingReason{
MainReasonName: "match_fail",
HostReasons: []hostReason{{Host: "t1", ReasonName: "match_fail"}},
ResReasons: []resReason{{Resource: "slots", Host: "t2", ReasonName: "no_resource"}},
}
s := pendReasonSummary(pr)
for _, want := range []string{"waiting:", "host t1", "resource slots on t2"} {
if !strings.Contains(s, want) {
t.Errorf("summary missing %q: %s", want, s)
}
}
}