forked from lei.chen/myxce
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:
parent
0e9609af6d
commit
67abbfa492
4 changed files with 264 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
122
src/frontend/mcp-server/internal/pendreason.go
Normal file
122
src/frontend/mcp-server/internal/pendreason.go
Normal 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")
|
||||
}
|
||||
73
src/frontend/mcp-server/internal/pendreason_test.go
Normal file
73
src/frontend/mcp-server/internal/pendreason_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue