diff --git a/src/frontend/mcp-server/cmd/main.go b/src/frontend/mcp-server/cmd/main.go index ac2d7cd..fd34ea9 100644 --- a/src/frontend/mcp-server/cmd/main.go +++ b/src/frontend/mcp-server/cmd/main.go @@ -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 diff --git a/src/frontend/mcp-server/internal/app.go b/src/frontend/mcp-server/internal/app.go index 6611104..3b8e5d1 100644 --- a/src/frontend/mcp-server/internal/app.go +++ b/src/frontend/mcp-server/internal/app.go @@ -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 { diff --git a/src/frontend/mcp-server/internal/pendreason.go b/src/frontend/mcp-server/internal/pendreason.go new file mode 100644 index 0000000..d8ce173 --- /dev/null +++ b/src/frontend/mcp-server/internal/pendreason.go @@ -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") +} diff --git a/src/frontend/mcp-server/internal/pendreason_test.go b/src/frontend/mcp-server/internal/pendreason_test.go new file mode 100644 index 0000000..4e7fcb5 --- /dev/null +++ b/src/frontend/mcp-server/internal/pendreason_test.go @@ -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) + } + } +}