Merge remote-tracking branch 'myxce/chatbot' into chatbot

This commit is contained in:
lei.chen 2026-09-01 00:44:36 +00:00
commit c3acd62a17
14 changed files with 584 additions and 14 deletions

View file

@ -122,7 +122,7 @@ spec 声称 36/36 任务完成,但 re-audit(research.md)发现缺陷。来
| Phase 2:SSE 事件流 | 计划 | apiserver SSE endpoint + EventBroker,schedulerclient `WatchJobs()`,mcp-server 接入。ADR-004 设计完成待实施。估 2-3 天。 |
| Phase 3:LSF adapter | 计划 | CLI 解析优先,Python Sidecar 后续。估 +1 月。 |
| Phase 4:Slurm + K8s | 计划 | slurmrestd + CLI fallback;client-go。估 +1 月。 |
| modify 工具 | 推迟 | apiserver 当前无 modify 端点(control 仅 kill/stop/resume/checkpoint),需新增后端端点。 |
| modify 工具 | 推迟 | `scheduler_modify(id, changes)` 全链路空白:controller `job_ctrl` 只有 siginfo+checkpoint 无改作业字段 action → 需 controller 新 opcode → apiserver 新端点 → schedulerclient → mcp 工具(见 §八.4)。kill/stop/resume 工具已有。 |
| 未决决策 | 未解决 | LLM 模型选型(Claude API/自建/混合);多集群 URM 源识别;air-gapped LLM 访问。 |
---
@ -198,13 +198,15 @@ spec 声称 36/36 任务完成,但 re-audit(research.md)发现缺陷。来
- Phase 4:**Slurm**(slurmrestd)+ **K8s**(client-go)(估 +1 月)
### 4. 推迟 / 未决
- `scheduler_modify` 工具推迟——apiserver 当前无 modify 端点(control 仅 kill/stop/resume/checkpoint),需新增后端端点
- `scheduler_modify(id, changes)` 工具推迟(MCP 缺口)——整条链路空白:controller `job_ctrl`(src/controller/job.c:957)仅 siginfo(SIGKILL/SIGSTOP/SIGCONT)+ checkpoint,无改作业字段的 action → 需 **controller 新 job-update opcode**(写回 queue/priority/resreq/user_priority + 触发调度器重新评估,大头)→ **apiserver** `POST /jobs/:jobids/modify`(routers.go 现有 create/get/list/output/control/restart,无 modify)+ 字段/状态校验(哪些字段可改、是否仅 PEND)→ **schedulerclient** `ModifyJob`(write.go 仅 Submit/Cancel/Hold/Release)→ **mcp-server** `scheduler_modify` 工具(~30 行,最小)。注:kill/stop/resume 三件套(`scheduler_cancel`/`hold`/`release`)已有;`scheduler_checkpoint` + `scheduler_restart` 已补(2026-08-30,17 工具,复用控制端 checkpoint action 与 `/jobs/:id/restart` 端点,单测通过)。
- 3 个未决决策:LLM 模型选型(Claude API/自建/混合)、多集群 URM 来源识别、air-gapped LLM 访问
- **PEND reason 分类体系(进行中,2026-08-14 已实施 P0+P1 部分)**:见 `docs/pend-reason-investigation-design.md`。**已完成(commit 4e2ac86)**:
- **code 策略已定**:扩展 PREASON_* 枚举到 9 个(BAD_RESREQ/NO_RESOURCE/MATCH_FAIL/LIMIT_GLOBAL/LIMIT_HOST/QUEUE_CLOSED/QUEUE_DRAINING/TIME_WINDOW/HOST_UNAVAIL),capnp UInt32 无限制。
- **P0**:main_reason 修复(首个 detail code 设为主因,集群验证 PEND 作业 main_reason 非 0 + reason_name)+ limit 插件 attach(host 限额→LIMIT_HOST+limit 名,全局限额→LIMIT_GLOBAL+limit 名)。**集群验证**:PEND 作业 API 显示 `main_reason:3 main_reason_name:"match_fail"` + 每条 host/res reason 带 reason_name。
- **P1 部分**:队列状态(sched_queue_is_ready 非 stub,closed/inactive 队列不参选 + fcfs election attach QUEUE_CLOSED/DRAINING);worker 过滤(sched_try_alloc_on_host_ 跳过 UNAVAIL/CLOSED worker + HOST_UNAVAIL)。
- **剩余**:P1 时间窗(TIME_WINDOW——scheduler 需保留 dispatchwindow 配置 + 自写"now in window"评估,controller 不计算窗口状态);P2 依赖/license/保留/exclusive/fairshare(机制本身未实现);MCP `bjobs -p` 等价工具(未做)。
- **⚠️ scheduler-framework 合并(2026-08-25)回归掉 P1**:P1 改动点(4e2ac86/ee79179)在旧 `fcfs.c`,框架合并用 `builtin_fcfs` 取代后旧 fcfs.c 成死路径;`resource.c`/`limit.c` 冲突取 theirs 直接丢;merge-fix 只补 dispatchwindow **字段**不补逻辑;验证从未跑。合并后 `sched_queue_is_ready`/`sched_job_is_ready` 是 `return 1` stub(core.c),仅 3 处 preason emit。
- **✅ P1 已恢复(2026-08-30)**:core.c 恢复 job 状态门 + queue 状态门;scheduler.c allocator 循环注入 `sched_queue_status_reason`(状态→QUEUE_CLOSED/DRAINING,时间窗→TIME_WINDOW,用 `xce_core_now` 虚拟时钟);resource.c 恢复 worker 过滤+HOST_UNAVAIL;limit.c 恢复 LIMIT_GLOBAL/HOST attach。**全部 reason attach 走 allocator 线程(bug-1521 安全)**。单测通过(新增 `test_core_ready_gates.c`,scheduler 套件 52/53,唯一失败是既有的 admin-request 校验顺序问题与 P1 无关)。遗留:持续 closed 队列的作业被 picker 跳过(spec 契约)到不了 allocator → 收不到 reason(合并前 #17 后同样如此,非新回归)。集群验证待做。
- **剩余(P2,超出 PEND reason 范畴,属 scheduler 特性开发)**:依赖 depend_cond / license / 保留 RsvID / exclusive / fairshare——机制本身在 scheduler 未实现(设计文档:调度器无处理);机制落地后 reason 自动出现,MCP 侧无需改。已收尾:TIME_WINDOW 已实现(ee79179,2026-08-15);`scheduler_pend_reasons` 工具(bjobs -p 等价)已上线(67abbfa,2026-08-15)。
### 5. 设计层加固(文档已写、代码零实现)
- **Auto-pilot 模式**:用户显式授权范围内的自主行为

View file

@ -25,7 +25,7 @@ const serverInstructions = "XCE scheduler agent — cluster status, not an incid
"root causes, do NOT reference \"known bugs\", and do NOT propose diagnostic or repair actions (checking logs, " +
"restarting daemons, opening tickets, ssh'ing to nodes, etc.) unless the user explicitly asks for help or " +
"remediation. If you believe action may be warranted, say so in at most one line and let the user decide.\n\n" +
"Write tools (scheduler_submit/hold/release/cancel) are destructive and require the user's explicit intent."
"Write tools (scheduler_submit/cancel/hold/release/checkpoint/restart) are destructive and require the user's explicit intent."
func main() {
baseURL := os.Getenv("XCE_API_URL")
@ -221,6 +221,22 @@ func main() {
mcp.WithDestructiveHintAnnotation(true),
), app.HandleReleaseJob)
// ── scheduler_checkpoint ──
s.AddTool(mcp.NewTool("scheduler_checkpoint",
mcp.WithDescription("Checkpoint a running job by ID (action 'checkpoint', CRIU). Destructive — you will be asked to confirm before execution."),
mcp.WithString("id", mcp.Description("Job ID to checkpoint"), mcp.Required()),
mcp.WithReadOnlyHintAnnotation(false),
mcp.WithDestructiveHintAnnotation(true),
), app.HandleCheckpointJob)
// ── scheduler_restart ──
s.AddTool(mcp.NewTool("scheduler_restart",
mcp.WithDescription("Restart (re-queue) a finished job as a new job by ID, resuming from its last checkpoint if one exists. Returns the new job ID. Destructive — you will be asked to confirm before execution."),
mcp.WithString("id", mcp.Description("Job ID to restart"), mcp.Required()),
mcp.WithReadOnlyHintAnnotation(false),
mcp.WithDestructiveHintAnnotation(true),
), app.HandleRestartJob)
log.Printf("Starting scheduler-agent MCP server (base-url: %s)", baseURL)
if err := mcps.ServeStdio(s); err != nil {
log.Fatalf("server error: %v", err)

View file

@ -288,6 +288,29 @@ func (a *App) HandleReleaseJob(ctx context.Context, req mcp.CallToolRequest) (*m
return textResultStr(fmt.Sprintf("job %s released (resume accepted)", id)), nil
}
func (a *App) HandleCheckpointJob(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id := getString(req, "id")
if id == "" {
return mcp.NewToolResultError("job id is required"), nil
}
if err := a.client.CheckpointJob(ctx, id); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("checkpoint job failed: %v", err)), nil
}
return textResultStr(fmt.Sprintf("job %s checkpoint accepted", id)), nil
}
func (a *App) HandleRestartJob(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id := getString(req, "id")
if id == "" {
return mcp.NewToolResultError("job id is required"), nil
}
res, err := a.client.RestartJob(ctx, id)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("restart job failed: %v", err)), nil
}
return textResult(res)
}
// ── helpers ─────────────────────────────────────────────────────────────────
// parseStatuses parses a comma-separated list of URM status strings.

View file

@ -18,12 +18,16 @@ type fakeClient struct {
submitSpec *sclient.JobSpec
submitErr error
cancelID string
cancelErr error
holdID string
holdErr error
releaseID string
releaseErr error
cancelID string
cancelErr error
holdID string
holdErr error
releaseID string
releaseErr error
checkpointID string
checkpointErr error
restartID string
restartErr error
}
func (f *fakeClient) SubmitJob(ctx context.Context, spec sclient.JobSpec) (*sclient.SubmitJobResult, error) {
@ -46,6 +50,19 @@ func (f *fakeClient) ReleaseJob(ctx context.Context, id string) error {
return f.releaseErr
}
func (f *fakeClient) CheckpointJob(ctx context.Context, id string) error {
f.checkpointID = id
return f.checkpointErr
}
func (f *fakeClient) RestartJob(ctx context.Context, id string) (*sclient.RestartJobResult, error) {
f.restartID = id
if f.restartErr != nil {
return nil, f.restartErr
}
return &sclient.RestartJobResult{JobID: 999, QueueName: "normal", Message: "ok"}, nil
}
func newTestApp(client sclient.Client) *App {
a := &App{client: client, templates: NewTemplateRegistry()}
return a
@ -288,6 +305,92 @@ func TestHandleReleaseJob(t *testing.T) {
}
}
func TestHandleCheckpointJob(t *testing.T) {
fake := &fakeClient{}
a := newTestApp(fake)
res, err := a.HandleCheckpointJob(context.Background(), idRequest("scheduler_checkpoint", "101"))
if err != nil {
t.Fatalf("HandleCheckpointJob error: %v", err)
}
if res.IsError {
t.Fatalf("expected success, got error: %s", resultText(t, res))
}
if fake.checkpointID != "101" {
t.Errorf("checkpointID = %q, want 101", fake.checkpointID)
}
if !strings.Contains(resultText(t, res), "checkpoint accepted") {
t.Errorf("result should mention checkpoint accepted, got: %s", resultText(t, res))
}
}
func TestHandleCheckpointJob_MissingID(t *testing.T) {
fake := &fakeClient{}
a := newTestApp(fake)
res, err := a.HandleCheckpointJob(context.Background(), idRequest("scheduler_checkpoint", ""))
if err != nil {
t.Fatalf("HandleCheckpointJob error: %v", err)
}
if !res.IsError {
t.Fatal("expected error for missing id")
}
if fake.checkpointID != "" {
t.Errorf("client should not be called, got checkpointID = %q", fake.checkpointID)
}
}
func TestHandleRestartJob(t *testing.T) {
fake := &fakeClient{}
a := newTestApp(fake)
res, err := a.HandleRestartJob(context.Background(), idRequest("scheduler_restart", "123"))
if err != nil {
t.Fatalf("HandleRestartJob error: %v", err)
}
if res.IsError {
t.Fatalf("expected success, got error: %s", resultText(t, res))
}
if fake.restartID != "123" {
t.Errorf("restartID = %q, want 123", fake.restartID)
}
if !strings.Contains(resultText(t, res), `"jobid": 999`) {
t.Errorf("result should include the new job id 999, got: %s", resultText(t, res))
}
}
func TestHandleRestartJob_ClientError(t *testing.T) {
fake := &fakeClient{restartErr: &fakeError{msg: "restart failed"}}
a := newTestApp(fake)
res, err := a.HandleRestartJob(context.Background(), idRequest("scheduler_restart", "123"))
if err != nil {
t.Fatalf("HandleRestartJob error: %v", err)
}
if !res.IsError {
t.Fatal("expected error result when client fails")
}
if !strings.Contains(resultText(t, res), "restart failed") {
t.Errorf("result should mention the client error, got: %s", resultText(t, res))
}
}
func TestHandleRestartJob_MissingID(t *testing.T) {
fake := &fakeClient{}
a := newTestApp(fake)
res, err := a.HandleRestartJob(context.Background(), idRequest("scheduler_restart", ""))
if err != nil {
t.Fatalf("HandleRestartJob error: %v", err)
}
if !res.IsError {
t.Fatal("expected error for missing id")
}
if fake.restartID != "" {
t.Errorf("client should not be called, got restartID = %q", fake.restartID)
}
}
// fakeError is a minimal error for tests.
type fakeError struct{ msg string }

View file

@ -51,6 +51,13 @@ type Client interface {
// ReleaseJob resumes a suspended job (action "resume", default SIGCONT).
ReleaseJob(ctx context.Context, id string) error
// CheckpointJob checkpoints a running job (action "checkpoint", CRIU).
CheckpointJob(ctx context.Context, id string) error
// RestartJob re-queues a finished job as a new job (resuming from its last
// checkpoint when one exists) and returns the new job's identity.
RestartJob(ctx context.Context, id string) (*RestartJobResult, error)
// ── Event stream (Phase 2) ──
// WatchJobs subscribes to the live job-event SSE stream and returns a

View file

@ -51,6 +51,12 @@ type JobControlRequest struct {
Method string `json:"method,omitempty"`
}
// JobRestartRequest is the body sent to POST /xce/v1/jobs/:jobids/restart.
// All fields are optional; an empty request restarts to any eligible host.
type JobRestartRequest struct {
AskedHosts string `json:"asked_hosts,omitempty"`
}
// ControlResult is the apiserver's per-item control response.
type ControlResult struct {
Status string `json:"status"` // accepted | failed | partially_accepted | no_items_processed
@ -58,6 +64,15 @@ type ControlResult struct {
Failed []ControlItem `json:"failed"`
}
// RestartJobResult is the apiserver's response to a job restart — a new job
// is created (optionally resumed from the old job's last checkpoint).
type RestartJobResult struct {
JobID int64 `json:"jobid"`
JobIDHuman string `json:"job_id_human"`
QueueName string `json:"queue_name"`
Message string `json:"message"`
}
// ControlItem reports the outcome for one job (or array element).
type ControlItem struct {
UniqueJobID int64 `json:"unique_job_id"`
@ -223,3 +238,29 @@ func (hc *HTTPClient) HoldJob(ctx context.Context, id string) error {
func (hc *HTTPClient) ReleaseJob(ctx context.Context, id string) error {
return hc.controlJob(ctx, id, "resume")
}
// CheckpointJob checkpoints a running job (action "checkpoint", CRIU).
func (hc *HTTPClient) CheckpointJob(ctx context.Context, id string) error {
return hc.controlJob(ctx, id, "checkpoint")
}
// RestartJob re-queues a finished job as a new job, resuming from its last
// checkpoint when one exists. Returns the new job's identity. The apiserver
// backs this with the same submit path, so a restart is accepted with 201.
func (hc *HTTPClient) RestartJob(ctx context.Context, id string) (*RestartJobResult, error) {
if strings.TrimSpace(id) == "" {
return nil, fmt.Errorf("restart job: job id is required")
}
url := hc.baseURL + "/xce/v1/jobs/" + id + "/restart"
env, err := hc.doPost(ctx, url, JobRestartRequest{}, http.StatusCreated)
if err != nil {
return nil, fmt.Errorf("restart job %s: %w", id, err)
}
var res RestartJobResult
if err := json.Unmarshal(env.Data, &res); err != nil {
return nil, fmt.Errorf("restart job %s: parse result: %w", id, err)
}
return &res, nil
}

View file

@ -282,6 +282,85 @@ func TestReleaseJob_SendsResume(t *testing.T) {
}
}
// ── Checkpoint / Restart ──────────────────────────────────────────────────────
func TestCheckpointJob_SendsCheckpoint(t *testing.T) {
hc, capture := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"code":202,"msg":"accepted","data":{"status":"accepted","accepted":[],"failed":[]}}`)
})
if err := hc.CheckpointJob(context.Background(), "101"); err != nil {
t.Fatalf("CheckpointJob returned error: %v", err)
}
_, path, _, body := capture()
if path != "/xce/v1/jobs/101/control" {
t.Errorf("path = %q, want /xce/v1/jobs/101/control", path)
}
if strings.TrimSpace(body) != `{"action":"checkpoint"}` {
t.Errorf("body = %s, want {\"action\":\"checkpoint\"}", body)
}
}
func TestCheckpointJob_StatusFailed(t *testing.T) {
hc, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"code":202,"msg":"failed","data":{"status":"failed","accepted":[],"failed":[{"unique_job_id":101,"job_id":101,"array_idx":0,"msg":"job 101 is in state DONE, cannot be checkpointed"}]}}`)
})
err := hc.CheckpointJob(context.Background(), "101")
if err == nil {
t.Fatal("expected error when control status is failed")
}
if !strings.Contains(err.Error(), "101") {
t.Errorf("error = %q, want to mention job id 101", err)
}
}
func TestRestartJob_Success(t *testing.T) {
hc, capture := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"code":201,"msg":"success","data":{"jobid":999,"queue_name":"normal","message":"Job <123> will be restarted with new job <999> to default queue <normal>."}}`)
})
res, err := hc.RestartJob(context.Background(), "123")
if err != nil {
t.Fatalf("RestartJob returned error: %v", err)
}
method, path, _, body := capture()
if method != http.MethodPost {
t.Errorf("method = %q, want POST", method)
}
if path != "/xce/v1/jobs/123/restart" {
t.Errorf("path = %q, want /xce/v1/jobs/123/restart", path)
}
if strings.TrimSpace(body) != "{}" {
t.Errorf("body = %s, want {}", body)
}
if res.JobID != 999 {
t.Errorf("JobID = %d, want 999", res.JobID)
}
if res.QueueName != "normal" {
t.Errorf("QueueName = %q, want normal", res.QueueName)
}
}
func TestRestartJob_EmptyID(t *testing.T) {
hc, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be called when id is empty")
})
_, err := hc.RestartJob(context.Background(), " ")
if err == nil {
t.Fatal("expected error for empty id")
}
if !strings.Contains(err.Error(), "job id is required") {
t.Errorf("error = %q, want 'job id is required'", err)
}
}
// ── conversion helpers ───────────────────────────────────────────────────────
func TestFormatWalltimeSeconds(t *testing.T) {

View file

@ -432,8 +432,41 @@ xce_sched_project_t* sched_get_project(xce_sched_core_t* core, char* name)
return make_sched_project(core, name);
}
/* FIXME this is just stub, we need to add more */
int sched_job_is_ready(xce_sched_job_t* j) { return 1; }
/* A job is electable only while it is PENDING and not suspended. Once
* dispatched (RUN), finished (EXIT/DONE), or suspended (PSUSP/USUSP/SSUSP)
* it must never be re-elected — bug-1498 (restored from the pre-framework
* scheduler.c after the scheduler-framework merge dropped it). */
int sched_job_is_ready(xce_sched_job_t* j)
{
if (j == NULL) {
return 0;
}
if ((j->state & JOB_STAT_PEND) == 0) {
return 0; /* already dispatched/running or terminal */
}
if (j->state & (JOB_STAT_PSUSP | JOB_STAT_USUSP | JOB_STAT_SSUSP)) {
return 0; /* suspended — not electable */
}
return 1;
}
/* FIXME this is just stub, we need to add more */
int sched_queue_is_ready(xce_sched_queue_t* q) { return 1; }
/* A queue is electable only while it accepts new jobs. The controller
* maintains q->status (closed / inactivated-by-admin / inactive-by-window).
* The dispatch window (q->dispatchwindow, from queues.yaml) is NOT evaluated
* here: core.c is isolated from wall-clock time (test_core_isolation.c) and
* the window can only be judged against the core's virtual clock
* (xce_core_now), which this signature has no access to. Windowed-out queues
* therefore pass this gate and are re-pended with PREASON_TIME_WINDOW by the
* allocator (scheduler.c, where xce_core_now() is reachable) — same net
* effect, with a safe (allocator-thread) reason attach. PEND-reason P1. */
int sched_queue_is_ready(xce_sched_queue_t* q)
{
if (q == NULL) {
return 0;
}
if (q->status
& (QUEUE_STAT_CLOSED | QUEUE_STAT_INACT_ADMIN | QUEUE_STAT_INACT_WINDOW)) {
return 0;
}
return 1;
}

View file

@ -284,6 +284,15 @@ static xce_sched_queue_t* core_build_queue(xce_queue_t* q)
queue->priority = q->priority;
queue->accept_interval = q->accept_interval;
queue->sched_delay = q->sched_delay;
/* PEND-reason P1 fix (2026-08-30): the scheduler-framework merge
* dropped the dispatch-window config here — make_sched_queue (schedobj.c)
* duplicates q->dispatchwindow_config, but this framework core path did
* not, so the TIME_WINDOW PEND reason could never fire on the live
* default path. Mirror make_sched_queue: dup the window (freed by
* kill_sched_queue via core->queues' datakiller). */
if (q->dispatchwindow_config != NULL) {
queue->dispatchwindow = dup_timewindow_config(q->dispatchwindow_config);
}
/* Same scope note as make_sched_queue's own (schedobj.c): project_name/
* job_pick_plugin have no wire-config source yet, and (matching
* existing behavior) resreq is not populated here either -- wiring
@ -336,6 +345,15 @@ static void core_upsert_queue(xce_sched_core_t* core, xce_sched_plugin_t* picker
FREEUP(q->resreq);
q->resreq = makeString(updated->resreq);
}
/* PEND-reason P1 fix (2026-08-30): same merge regression as
* core_build_queue above — the update branch never carried the
* dispatch window. Refresh it so a QUEUE_UPDATE that changes the
* window (or a re-push after the initial build) takes effect. */
if (updated->dispatchwindow_config != NULL) {
free_timewindow_config(q->dispatchwindow);
q->dispatchwindow = dup_timewindow_config(updated->dispatchwindow_config);
}
}
if (picker != NULL) {

View file

@ -6,6 +6,7 @@
#include "kvmgr.h"
#include "plugin.h"
#include "preason.h"
#include "sched/types.h"
#include "scheduler.h"
#include "utils.h"
@ -208,6 +209,17 @@ static int match_job_global_limit_(xce_sched_plugin_t* plugin,
TRACE("%s match_limit limit %s user %s queue %s project %s rc %d", __func__,
limit->name, obj->user, obj->queue, obj->project, rc);
if (rc <= 0) {
/* global quota hit — report the specific limit (PEND-reason P1,
* restored from 4e2ac86). The election return value is ignored by
* the main loop, so the reason is carried on ctx->reason and moved
* to obj->reason by limit_job_election. */
if (ctx->reason == NULL) {
ctx->reason = make_pendreason(0);
}
if (ctx->reason != NULL) {
add_job_preason(ctx->reason->reasons, PREASON_LIMIT_GLOBAL, NULL,
limit->name);
}
return rc;
}
@ -299,6 +311,14 @@ static int limit_job_election(void* arg, xce_sched_job_t* job)
iter_destroy(&it);
/* Carry any global-quota reason (PREASON_LIMIT_GLOBAL) attached by
* match_job_global_limit_ onto the job so it survives to the PEND
* decision. The election's rc itself is ignored by the main loop.
* (PEND-reason P1, restored from 4e2ac86.) */
if (ctx.reason != NULL) {
MOVE_PTR(obj->reason, ctx.reason);
}
obj->configver = sched->core.configver;
return rc;
@ -358,6 +378,16 @@ static int match_job_host_limit_(xce_sched_plugin_t* plugin,
TRACE("%s match_limit limit %s user %s queue %s project %s rc %d", __func__,
limit->name, obj->user, obj->queue, obj->project, rc);
if (rc <= 0) {
/* host-based quota hit — report the specific limit (PEND-reason P1,
* restored from 4e2ac86; previously the block was silent and the job
* just stayed PEND). */
if (ctx->reason == NULL) {
ctx->reason = make_pendreason(0);
}
if (ctx->reason != NULL) {
add_job_preason(ctx->reason->reasons, PREASON_LIMIT_HOST,
worker->name, limit->name);
}
return rc;
}

View file

@ -484,6 +484,23 @@ static int sched_try_alloc_on_host_(xce_sched_worker_t* w, va_list ap)
int rc;
xvec_t* uncomit = NULL;
/* Worker down/closed — never eligible. Attach HOST_UNAVAIL instead of
* trying to allocate on it (PEND-reason P1, E17 — restored from
* 4e2ac86 after the scheduler-framework merge dropped it). Runs in the
* allocator thread (sched_try_alloc_on_hosts), so ctx->reason is safe
* to touch here. */
if (w->status
& (XCE_WORKER_UNAVAIL | XCE_WORKER_CLOSED | XCE_WORKER_CLOSED_BY_ADMIN)) {
if (ctx->reason == NULL) {
ctx->reason = make_pendreason(0);
}
if (ctx->reason != NULL) {
add_job_preason(ctx->reason->reasons, PREASON_HOST_UNAVAIL,
w->name, NULL);
}
return 0;
}
rc = sched_meet_host_selection(sched, w, primary->select);
if (rc == 0) {
if (ctx->reason == NULL) {

View file

@ -5,6 +5,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "client.h"
#include "controller.capnp.h"
@ -266,6 +267,39 @@ static int sched_make_alter_allocation(xce_scheduler_t* sched,
return 0;
}
/* Map a queue to the PEND reason it should report (PEND-reason P1 restore,
* resurrected from the pre-framework scheduler.c). Status flags take
* precedence, then the dispatch window. The window is evaluated against the
* core's virtual clock (xce_core_now, /1e9 → seconds) so live and sim modes
* agree; a NULL/empty window is always open. Runs only from the allocator
* thread (see the gate at the top of the allocator loop), so its reason
* attachment cannot race MOVE_PTR(obj->reason, ...) (bug-1521). */
static int sched_queue_status_reason(xce_scheduler_t* sched,
xce_sched_queue_t* q)
{
if (q == NULL) {
return 0;
}
if (q->status & QUEUE_STAT_CLOSED) {
return PREASON_QUEUE_CLOSED;
}
if (q->status & QUEUE_STAT_INACT_ADMIN) {
return PREASON_QUEUE_DRAINING;
}
if (q->status & QUEUE_STAT_INACT_WINDOW) {
return PREASON_TIME_WINDOW;
}
if (q->dispatchwindow != NULL) {
uint64_t now_ns = xce_core_now(&(sched->core));
if (!xce_timewindow_is_open(q->dispatchwindow,
(time_t)(now_ns / 1000000000ULL))) {
return PREASON_TIME_WINDOW;
}
}
return 0;
}
static int sched_make_allocation(xce_scheduler_t* sched, xce_sched_obj_t* obj)
{
int rc = 0;
@ -326,6 +360,7 @@ static void* sched_allocator(void* arg)
xce_decision_t* decision = NULL;
xce_sched_job_t* job = NULL;
int alloc_rc = 0;
int qr = 0;
pthread_testcancel();
@ -346,6 +381,35 @@ static void* sched_allocator(void* arg)
plugin_job_election(sched, job);
/* PEND-reason P1 restore (bug-1521-safe): a job whose queue is not
* ready (closed / drained / outside its dispatch window) must not be
* dispatched. The gate and reason attach live HERE (allocator thread)
* rather than in the picker: the picker thread writing
* job->schedobj->reason races this thread's MOVE_PTR(obj->reason, ...)
* (bug-1521 heap corruption). The picker still skips persistently-
* closed queues per its spec contract, so this path fires for queues
* that close or window-out between pick and allocation, and for
* windowed-out queues that pass the picker's status-only gate. */
qr = sched_queue_status_reason(sched, job->queue);
if (qr != 0) {
xce_pendreason_t* qreason = make_pendreason(0);
job_picker->picker_iface->on_pend(job_picker, jobid);
if (qreason != NULL) {
qreason->jobid = job->jobid;
add_job_preason(qreason->reasons, qr,
job->queue != NULL ? job->queue->name : NULL,
NULL);
decision = make_pend_decision(qreason);
}
if (decision != NULL) {
commit_decision(sched, decision);
cbuf_put(&(sched->core.decisions), &decision);
}
continue;
}
/* calculate decision for jobid */
alloc_rc = sched_make_allocation(sched, job->schedobj);
if (alloc_rc < 0) {

View file

@ -264,6 +264,19 @@ sched_core_jbuf_capacity_pin_test = executable(
+ '"'],
install: false)
# Regression guard for the PEND-reason P1 readiness gates restored after
# the scheduler-framework merge dropped them (sched_job_is_ready /
# sched_queue_is_ready were `return 1` stubs in core.c). Same white-box
# #include pattern as test_core_jbuf_capacity_pin.c (pulls the real
# ../src/core.c into this TU); exercises the REAL gate functions, not a
# re-implementation.
sched_core_ready_gates_test = executable(
'sched_core_ready_gates_test',
['test_core_ready_gates.c'],
include_directories:[incdir],
dependencies: [scheduler_test_deps],
install: false)
# Contract test for the core event interface and batch event push
# (scheduler-core spec, task 4.3, Req 5 AC 4 / AC 8): xce_core_push_events()
# fires the resolved picker's corresponding state-change hook exactly once
@ -1235,6 +1248,9 @@ test('scheduler no bare printf test',
test('scheduler core jbuf capacity pin test',
sched_core_jbuf_capacity_pin_test, suite: 'scheduler',
env: ['ASAN_OPTIONS=detect_leaks=0'])
test('scheduler core ready gates test',
sched_core_ready_gates_test, suite: 'scheduler',
env: ['ASAN_OPTIONS=detect_leaks=0'])
test('scheduler core events test',
sched_core_events_test, suite: 'scheduler',
env: ['ASAN_OPTIONS=detect_leaks=0'])

View file

@ -0,0 +1,121 @@
/*
* test_core_ready_gates.c - contract test for the PEND-reason P1 readiness
* gates restored after the scheduler-framework merge dropped them
* (sched_job_is_ready / sched_queue_is_ready were `return 1` stubs in
* core.c; the pre-framework scheduler.c implementations were lost).
*
* Observable under test:
* - sched_job_is_ready(): a job is electable only while PENDING and not
* suspended. NULL / RUN / EXIT / DONE / PSUSP / USUSP / SSUSP -> 0.
* PEND -> 1. (bug-1498: a running job lingering in the pendJobs queue
* must never be re-elected and re-dispatched.)
* - sched_queue_is_ready(): a queue is electable only while it accepts
* new jobs. NULL / QUEUE_STAT_CLOSED / QUEUE_STAT_INACT_ADMIN /
* QUEUE_STAT_INACT_WINDOW -> 0. Status 0 -> 1. The dispatch-window
* half of the gate intentionally lives in the allocator (scheduler.c,
* xce_core_now) not here: core.c is isolated from wall-clock time, and
* the queue has no back-pointer to the core (see the function's own
* comment in core.c).
*
* White-box #include of ../src/core.c (same convention as
* test_core_jbuf_capacity_pin.c): these tests exercise the REAL core.c
* functions, not a re-implementation. Stub surface is exactly the seven
* kvmgr datakillers/plugin_unregister core.c references as addresses in
* xce_sched_core_init() (never actually invoked -- fixtures aren't torn
* down, same convention as the sibling core tests).
*/
#include <unity/unity.h>
#include "sched/types.h"
#include "xce/types.h"
#include "../src/core.c"
/* Linker stubs for core.c's extern datakillers (never invoked). */
void kill_sched_job(void* arg) { (void)arg; }
void kill_sched_queue(void* arg) { (void)arg; }
void kill_sched_worker(void* arg) { (void)arg; }
void kill_sched_user(void* arg) { (void)arg; }
void kill_sched_project(void* arg) { (void)arg; }
void kill_sched_resval(xce_val_t* val) { (void)val; }
void plugin_unregister(void* arg) { (void)arg; }
void setUp(void) { return; }
void tearDown(void) { return; }
static void test_job_is_ready_rejects_null(void)
{
TEST_ASSERT_EQUAL_INT(0, sched_job_is_ready(NULL));
}
static void test_job_is_ready_accepts_pend(void)
{
xce_sched_job_t j = { .state = JOB_STAT_PEND };
TEST_ASSERT_EQUAL_INT(1, sched_job_is_ready(&j));
}
static void test_job_is_ready_rejects_non_pend(void)
{
const uint32_t states[] = { 0, JOB_STAT_RUN, JOB_STAT_DONE, JOB_STAT_EXIT };
size_t i;
for (i = 0; i < sizeof(states) / sizeof(states[0]); i++) {
xce_sched_job_t j = { .state = states[i] };
TEST_ASSERT_EQUAL_INT_MESSAGE(0, sched_job_is_ready(&j),
"non-PEND job must not be electable");
}
}
static void test_job_is_ready_rejects_suspended(void)
{
const uint32_t susp[] = { JOB_STAT_PSUSP, JOB_STAT_USUSP, JOB_STAT_SSUSP };
size_t i;
for (i = 0; i < sizeof(susp) / sizeof(susp[0]); i++) {
xce_sched_job_t j = { .state = JOB_STAT_PEND | susp[i] };
TEST_ASSERT_EQUAL_INT_MESSAGE(0, sched_job_is_ready(&j),
"suspended job must not be electable");
}
}
static void test_queue_is_ready_rejects_null(void)
{
TEST_ASSERT_EQUAL_INT(0, sched_queue_is_ready(NULL));
}
static void test_queue_is_ready_accepts_open(void)
{
xce_sched_queue_t q = { .status = 0 };
TEST_ASSERT_EQUAL_INT(1, sched_queue_is_ready(&q));
}
static void test_queue_is_ready_rejects_closed_or_inactive(void)
{
const uint32_t statuses[] = { QUEUE_STAT_CLOSED, QUEUE_STAT_INACT_ADMIN,
QUEUE_STAT_INACT_WINDOW };
size_t i;
for (i = 0; i < sizeof(statuses) / sizeof(statuses[0]); i++) {
xce_sched_queue_t q = { .status = statuses[i] };
TEST_ASSERT_EQUAL_INT_MESSAGE(0, sched_queue_is_ready(&q),
"closed/inactive queue must not be electable");
}
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_job_is_ready_rejects_null);
RUN_TEST(test_job_is_ready_accepts_pend);
RUN_TEST(test_job_is_ready_rejects_non_pend);
RUN_TEST(test_job_is_ready_rejects_suspended);
RUN_TEST(test_queue_is_ready_rejects_null);
RUN_TEST(test_queue_is_ready_accepts_open);
RUN_TEST(test_queue_is_ready_rejects_closed_or_inactive);
return UNITY_END();
}