The apiserver stayed circuit-breaker-degraded after a full controller
restart until manually restarted. Two root causes:
1. Breaker recovery was decoupled from the breaker itself. The breaker can
be tripped by a backend write failure (e.g. E_XCE_STANDBY -10014 while
the controller is electing) with no heartbeat ever failing, but
StartHeartbeat only recovered on a fail→success heartbeat transition
(prevFailed), so the breaker stayed degraded forever.
→ extract needsRejoinAfterHeartbeat(hbOK, prevFailed): recover when
prevFailed OR the breaker is currently tripped. 5 new unit tests.
2. The 30s heartbeat's fire-and-forget reconnect only fires when the lib's
internal ctx state is XCE_CTX_ERR. After a controller restart whose
election outlasts the FAILOVER_CALL retry budget, the retry loop's
xcecli_ctx_recover leaves the ctx RUN on a half-open connection, so the
FF path never reconnects and the client stays disconnected forever.
→ lib: new xcecli_force_reconnect() forces a rotate+recover regardless
of state; apiserver: 2s recoveryLoop force-reconnects + rejoins while
degraded.
Verified on cluster: after controller kill+restart, the apiserver logs
"backend re-joined controller after reconnect" and job submissions resume
without a manual restart; a fresh job runs to DONE. Controller, scheduler
(rejoin ok, bug-1513) and workers all auto-recover.
Co-Authored-By: Claude <noreply@anthropic.com>
component-failover-adoption Req 4: when the apiserver's controller connection
is down, backend-requiring API requests must return a clear service-unavailable
error rather than hanging. Adds:
- health.go: an atomic circuit breaker (IsDegraded/MarkDegraded/MarkHealthy).
- callBackend: the four write calls (jobsubmit/jobctrl/queuectrl/hostctrl)
now route through it — degraded ⇒ immediate "backend controller unavailable"
error without invoking the lib; a transport OR XCE not-ready/masterless/
standby failure (E_XCE_NOMASTER/REDIRECT/NOT_READY/STANDBY) trips the breaker
so the NEXT call fails fast. Semantic rejections (-EINVAL/-ENOENT/E_XCE_BAD_*)
never trip it.
- StartHeartbeat: a failed tick marks degraded; recovery re-joins (rejoinBackend)
and marks healthy.
Verified live (t1 cluster): kill controller → first submit returns
xcecli_jobsubmit failed: -10013 (bounded, no hang), second submit returns
"backend controller unavailable (reconnecting)" in ~11ms. 7 unit tests.
Recovery-via-heartbeat after a FULL controller death is NOT yet reliable — the
lib transport reconnect does not always re-establish to the restarted
controller (same class as the scheduler reconnect; tracked under task 13).
Co-Authored-By: Claude <noreply@anthropic.com>
component-failover-adoption Req 2 (partial): two of the three daemons now
self-heal after a controller restart without an operator restart.
- worker (controller_connector.c): checking_connection() previously gave up
after 5 retries and called handle_disconnect() (which set running=false and
exited the connector thread). It now retries indefinitely — the connector
thread is dedicated to this loop so the unbounded retry/sleep cannot block
the worker's other liveness duties. Verified live: killing the controller
leaves all 3 workers alive and retrying; they re-join when it returns.
- apiserver (clib.go): xcecli_heartbeat is fire-and-forget — it best-effort
reconnects the transport but never re-joins, so after a controller restart
push notifications stop. StartHeartbeat now tracks heartbeat failure→success
and, on recovery, calls xce_backend_rejoin (xcecli_join as HostRole_apiserver)
to re-register and resume pushes. Verified live: "backend re-joined
controller after reconnect" logged during a controller kill/restart.
Scheduler reconnect NOT included: the rejoin works but exposes a pre-existing
libxce race — xce_ctx_reconnect's endpoint_fini tears down the old endpoint
while a lib vtask/io thread is still processing it, a deterministic double-free
(bad-free in the loader, same freed address offset across runs). Root-cause in
libxce netio/endpoint lifecycle is a separate task (documented in
left_scheduler_work.md).
Co-Authored-By: Claude <noreply@anthropic.com>
The pending_reason field was declared on models.JobInfo but never populated:
scheduler computed PEND reasons and the controller stored them on
job->pend_reasons, yet the data stopped there. Two gaps closed:
1. controller/apiserver.c: apiserver_job_hook_fun now handles
HOOK_DETAIL_SETPREASON by pushing the updated job to API servers (reusing
apiserver_job_update_fun). Without this, a job whose PEND reasons change
never reached the API server — only JOB_NEW/UPDATE/START/EXIT events did,
and setpreason fired none of those.
2. backend/convert.go + convert_pure.go: cJobToModel / rawJobToModel now
extract xce_job_t.pend_reasons (main_reason + host/res reasons) and
marshal them into models.JobInfo.PendingReason as JSONB. models/jobs.go:
pending_reason added to JOB_DEFAULT_COLUMN so default job queries return
it (it was only in JSONBJFields, never selected).
Verified live on t1/t2/t3: a job with resreq "type == nosuchhost" stays
PEND and returns
"pending_reason":{"main_reason":0,"host_reasons":[{"host":"t1","reason":3},
{"host":"t2","reason":3},{"host":"t3","reason":3}]}
(main_reason=3 PREASON_MATCH_FAIL per host) via both /jobs list, /jobs/:id
and the SSE stream. 4 new unit tests (rawJobToModel pendReason marshal +
name mapping), all apiserver backend tests pass.
Co-Authored-By: Claude <noreply@anthropic.com>
The MCP/apiserver returned the raw 64-bit job ID (e.g. 73930964699447296)
while xce_test/xcecli display the dash form (e.g. 1-0-1786585485-0) that
parse_jobid accepts. A user taking the numeric form into xce_test fails to
parse it. The apiserver is the data-correctness owner (MCP only reads), so
it now emits the controller's fmt_jobid form alongside the numeric ID:
- models: JobSubmitResponse gains job_id_human; job list rows gain
job_id_human (processJobData), using the existing backend.FormatJobID.
- schedulerclient: URMJob.HumanID + SubmitJobResult.JobIDHuman parse it.
- Tests: backend.FormatJobID (real t1 job, pre-cluster1 job, array child);
processJobData adds job_id_human.
Also rename the SSE permission event:subscribe → event:view so mcp_readonly
stays strictly *:view (the read-only invariant test enforces).
Verified live on t1: scheduler_submit returns job_id_human 1-0-1786586474-0
and scheduler_get_job returns human_id.
Co-Authored-By: Claude <noreply@anthropic.com>
Add four tools closing the submit→watch→analyze→suggest loop in the
request/response MCP model:
- scheduler_wait_job: poll a job to a terminal state or timeout.
- scheduler_analyze_failure: gather job record + output logs for failure
analysis (interpretation left to the LLM).
- scheduler_suggest_resources: conservative processor/node suggestions from
current cluster availability.
- scheduler_estimate_wait_time: rough wait estimate from pending count and
free cores (guidance, not a guarantee).
All verified live on t1 against the running apiserver.
Co-Authored-By: Claude <noreply@anthropic.com>
Add WatchJobs(ctx) to the Client interface and HTTPClient: opens the
apiserver's /xce/v1/events SSE stream and yields JobEvent values until the
context is cancelled or the stream ends. JobEvent carries the event type,
job id, status string, exit code, and a raw job snapshot.
Also fix ListJobs' filter params: the apiserver accepts filters via the
"filter" query param in field:op:value form (e.g. queue_name:eq:normal);
the individual queue_name/submit_user params it ignores with a 400, which
broke every filtered list. Multiple conditions are brace-wrapped {a},{b}.
Verified live on t1: WatchJobs captured job.updated PEND→RUN→DONE for a
submitted job.
Co-Authored-By: Claude <noreply@anthropic.com>
Add a Server-Sent Events endpoint /xce/v1/events streaming job lifecycle
events (job.created/updated/deleted/finished) to subscribers. The
DefaultHandler now publishes every job event to a process-wide EventBroker;
the SSE handler subscribes and streams JSON frames with a keep-alive comment
during idle.
- backend/broker.go: thread-safe fan-out Broker (drops for slow consumers so
the notification path never blocks), Event type with job_status string.
- backend/handler.go: OnJob* publish to DefaultBroker.
- controllers/events.go: SSE handler (gin, text/event-stream, keep-alive).
- router + whitelist + rbac: /xce/v1/events route, event:subscribe
permission granted to mcp_readonly/mcp_operator/cluster_admin.
- Tests: broker fan-out/slow-consumer/unsubscribe, handler publishes, SSE
end-to-end via httptest.
Verified live on t1: SSE stream delivered job.updated PEND→RUN events for a
submitted job.
Co-Authored-By: Claude <noreply@anthropic.com>
When a user asks to view cluster status (e.g. "看看机器情况"), the LLM saw
non-OK values in the MCP response (a node UNAVAIL, a job PEND/EXIT) and
treated them as incidents — proposing unsolicited diagnostic/repair actions
and referencing "known bugs". The user only wanted a factual status report.
Mitigation at the MCP layer:
- Add server instructions (returned to the client in the initialize
handshake) stating that the status tools return operational snapshots,
that UNAVAIL/PEND/EXIT/UNKWN are normal state, and that the model must
not speculate or propose actions unless the user explicitly asks.
- Append report-only guidance to the scheduler_list_nodes / list_jobs /
cluster_metrics descriptions.
Verified: initialize response carries the 857-char instructions; tools/list
shows the updated descriptions.
Co-Authored-By: Claude <noreply@anthropic.com>
GetJobByIDs (GET /xce/v1/jobs/:jobids) built its job_id filter via
ConvertToNewFilterArray into QueryOptions.CompConditions, but ExecuteQuery
only ever applied opts.Conditions and silently dropped CompConditions. Every
per-id job query therefore returned the ENTIRE job table, and the MCP
scheduler_get_job tool returned the first job in the store regardless of the
requested id (observed: get_job(73930445842022400) -> "post-clean").
Fix: add FilterCompConditions (per-field AND, OR across ConditionArrays,
main-condition AND OR(sub-conditions) within an array) and run it in
ExecuteQuery after the flat filter; extract the bitmask-aware per-row match
into rowMatchesCondition shared by FilterRows and FilterCompConditions.
Verified via the MCP server: scheduler_get_job now returns the requested job
(mcp-e2e, EXIT), and the full submit->hold->release->cancel->get cycle still
works.
Co-Authored-By: Claude <noreply@anthropic.com>
Two stacked defects in the apiserver job-control link, both of which kept
hold/release/cancel from ever reaching the controller:
1. controllers/jobs.go: validateJobControl and foundJob asserted
jobinfo.Data["array_idx"].(int64), but StoreToOrderMaps produces
array_idx as int (models.JobInfo.ArrayIdx is declared int) -> panic
-> Gin Recovery -> HTTP 500 before backend.ControlJob ran. Add
normalizeJobInt64 / jobArrayIdx helpers and use them in both
functions (job_status already had this fix via normalizeJobStatus;
array_idx was missed).
2. backend/clib.go: backend.ControlJob set xce_jobctrl_t.action to the
models.JOB_* constant (JOB_STOP=1 / JOB_RESUME=2 / JOB_KILL=3 /
JOB_CHKPNT=8), but that field is the capnp JobControl union
discriminant (JobControl_u_siginfo=0 / JobControl_u_ckptoption=1).
The controller's job_ctrl() selected the wrong union branch (or
none, since encode_JobControl drops the union pointer for
non-0/1 discriminants) and rejected every control with -EINVAL.
Add jobCtrlDiscriminant() and use it.
Also: resolveExecUser was defined in the cgo-only convert.go but is
exercised by a non-cgo test, so `make test` (CGO_ENABLED=0) failed to
build the backend package. Move it to convert_pure.go so it compiles in
both modes.
Tests: 3 controller regression tests (validateJobControl allowed /
not-allowed branches, foundJob array-child branch) + 1 backend
jobCtrlDiscriminant test. make test fully green. Cluster E2E verified:
hold -> PSUSP, release -> PEND, cancel -> EXIT on a PEND job, no 500,
no -EINVAL, all daemons healthy.
Co-Authored-By: Claude <noreply@anthropic.com>
`xcecli jobs list -a` returned "no job found" even when jobs existed.
FilterRows routes every condition on a bitmask field (job_status /
host_status) to MatchesBitmaskCondition, which only implemented OpEQ/OpNE
bitmask semantics — the `-a` filter compiles to `job_status notnull`
(OpNOTNULL), which fell through to `return false` and filtered out every
job. (The default list's `notin(EXIT,DONE)` is compiled to OpEQ(mask), so
it was unaffected.)
Add OpISNULL/OpNOTNULL branches to MatchesBitmaskCondition that test the
raw field value. New query_test.go: 3 regression tests (notnull matches
all, null matches none, bitmask notin path unaffected), RED->GREEN.
Verified live: `xcecli jobs list -a` now lists all jobs including DONE.
Co-Authored-By: Claude <noreply@anthropic.com>
tryConnect built the xce_client_config_t with C.CString(host/from/key), then a
defer freed all three. But xcecli_ctx_init stores the config POINTER
(ctx->conf = config), not a copy — client_failover.c dereferences
config->masters/from/key for the client's ENTIRE lifetime (e.g.
xce_failover_next_target strncpy's a master host on every write-fail/redirect).
Freeing the strings left config->masters[0] dangling → ASAN heap-use-after-free
(strncpy in the background C thread) after a few submits.
Fix: keep the three C strings for the process lifetime (tryConnect runs once at
startup, gCtx is a process-global); free them only on the init-failure path.
Verified: 6 consecutive job submits, apiserver stays up, 0 ASAN errors
(previously crashed after ~2-3 submits).
Co-Authored-By: Claude <noreply@anthropic.com>
modelJobToSubmit built the C job_startinfo_t without setting execuser, so the
controller forwarded a NULL execuser in the startjob request and the worker
ran every job as "nobody". Now the submit carries the resolved exec user: an
explicit exec_user wins, otherwise the job runs as the submitting user.
- add resolveExecUser() (plain-Go, unit-testable) + TestResolveExecUser
- set si.execuser in modelJobToSubmit, free it in freeJobSubmit
Note: deploying this exposed an ASAN heap-use-after-free in the apiserver's
submit/notification path (strncpy reading freed memory); the fix is committed
but needs the memory-lifecycle investigation before it can ship safely.
Co-Authored-By: Claude <noreply@anthropic.com>
The apiserver returns list rows in OrderMap columnar form —
{"Fields":["col",...],"Data":{"col":val,...}} — but the read clients
unmarshalled each row as a flat object, so every field came back empty
(counts were right, values were all zero/"").
Fix: decode rows as apiRow{Fields, Data map[string]json.RawMessage} and
pull named fields out of Data via rowStr/rowInt/rowInt64/rowFloat helpers.
ListNodes, ListQueues, and getJobs (ListJobs/GetJob) all use apiRow now.
TDD: 3 new ordermap_test.go tests built from live-captured apiserver
response shapes (red: fields empty → green: parsed). Live-verified against
t1: node name=t1 cores=8, 4 queues active, 3 jobs with real fields.
Co-Authored-By: Claude <noreply@anthropic.com>
Three fixes found during scheduler-agent Phase 1 cluster verification:
- whitelist.go: add /xce/v1/apikeys to WhitelistPOSTURL (Phase 0 added the
route but never whitelisted it → 403 "URL is not allowed"). bug-1489.
- jobs.go: fix job_status int64 assertion in job control. store.Jobs returns
status as int/float64; the `.(int64)` assertion broke control with
"job_status is not an int64". Add normalizeJobStatus() accepting int/int32/
int64/float64/json.Number.
- rbac.yaml: mcp_operator role gains queue:control (the job-control handler
also checks queue:control on the job's queue); add rbac_mcp_operator_test.
Co-Authored-By: Claude <noreply@anthropic.com>
rshen/Letmein was a live, checked-in cluster_admin credential in
auth.yaml. Renamed to a non-identifying username with a clearly-marked
placeholder password.
docs: add full ops deployment guide (src/docs/deployment.md)
Covers building and running all four daemons plus xceadmin, based on
direct source verification (CLI flags, config file shapes, TLS posture)
and a live single-node deploy. Documents real production gaps (no
process supervision, plaintext non-controller traffic, exposed dev
creds) rather than treating the repo as a finished runbook.
The apiserver backend connects to the controller, which defaults
to port 9990 (XCE_CONTROLLER_DEFAULT_PORT). The value 9901 had no
references in the codebase and was likely a debug/testing artifact.
Co-Authored-By: Claude <noreply@anthropic.com>
bug-1463: commit 94f50ce6 (bug-1453 CA-cert-plumbing removal) missed
GetJobOutput's dead tls.Config/loadRootCAs block in request.go, plus
peek.go's SetRootCAs call and serverInfo.CACert field access (the
field no longer exists on APIServerInfo) -- broke the xcecli build.
Removed all three dangling references. Also enabled NewAPIClient's
previously-commented-out InsecureSkipVerify TLS config, since no
xcecli method set any TLS config at all -- any https:// apiserver URL
would have failed cert verification, not just GetJobOutput. Matches
bug-1453's documented design (server-side TLS only, no peer
verification, mTLS deferred as P3 hardening).
Documented both the api-server (http+https) and controller
(server-side-cert-only, no client certs) TLS postures in
.kiro/steering/security.md, replacing a stale line claiming TLS is
handled at the network layer, plus a follow-up note for the planned
HAProxy TLS-termination reverse proxy once its cert type and
apiserver's own listener fate are decided.
controller.capnp declared `struct Data $C.mapname("xce_data_t")` (an
OPCode_get query-result tagged union) at file scope, which shadows
Cap'n Proto's builtin Data blob type for every later `: Data` field in
the same file. This silently miscompiled 7 fields across 6 structs as
a struct-pointer type instead of a raw byte blob: UpgradeConfigRequest
.sha256/.bytes, ReplConfigReply.sha256/.bytes, ReplConfigPushRequest
.sha256/.bytes, ReplEnvelope.payload, SnapshotTransfer's chunk
payload, WalCatchupEntry.payload/.batchPayloads, and CtrlHelloRequest
.key. Production C code had already routed around it for years via
hand-rolled raw capn_ptr manipulation (see request.c's own comments);
nothing before now needed the generated codec/accessors to actually
work for these fields.
Renamed the local struct to XceData (capnp-level name only; the
$C.mapname("xce_data_t") C typedef is unchanged) and explicitly
repointed getdata/pushmsg at XceData by name, since those two fields
correctly want the local tagged union. Updated every derived
generated-identifier reference across 19 hand-written files spanning
controller, lib, scheduler, and the Go apiserver/xcecli CGo bindings.
Fixed 5 call sites in lib/request.c that had been typed against the
old workaround alias but now legitimately receive the correct
capn_data type (a pure type-name swap -- Data_ptr/XceData_ptr and
capn_data share the identical {capn_ptr p} layout).
Verified via full rebuild+test: lib (1/1), controller (3/3, incl. the
job-array push regression tests), scheduler (15/15), Go apiserver +
xcecli (all packages green), and a direct pycapnp round-trip proving
UpgradeConfigRequest.sha256/.bytes now encode/decode correctly.
Bug: bug-1461 (.wolf/buglog.json). Unblocks admin-config-tool task 3.3.
bug-1453: remove unused client-side CA-certificate plumbing. C-side:
drop ca_cert_path from xce_cluster_config_t, do_connect()'s ca_cert
param, --ca-cert flags on worker/scheduler, and controller's cert
population (TLS is server-side only, SSL_VERIFY_NONE everywhere on
the client side already, verified zero SSL_CTX_load_verify_locations
calls in the C tree). Go-side: delete xcecli's self-signed cert
TOFU-pinning flow (cmd/auth/cert/*), CACert field, SetRootCAs/
loadRootCAs, and every call site.
bug-1454: fix xcecli_ctx_t leak in scheduler.c sched_init() — the
unconditional initial ctx_init was never freed before the HA
candidate loop re-initialized it on the first iteration.
bug-1455: sched_init()'s memset(sched, 0, ...) was silently wiping
sched->cluster_cfg that main.c had populated from --peers just before
calling sched_init(), so --peers was always ignored and the scheduler
fell back to the hardcoded localhost:9990 single-host default.
Restructured sched_init() to take cluster_cfg as an explicit parameter
(assigned after the memset) instead of relying on caller-side
ordering with a separate sched_set_cluster_cfg() setter, which is now
removed.
bug-1456: revert an unsafe stack-local xce_client_config_t introduced
while fixing bug-1455 — xcecli_ctx_init() stores that pointer as
ctx->conf and client_failover.c dereferences it for the ctx's entire
life (runtime reconnect), so a per-loop-iteration stack copy would
dangle the moment sched_init() returned. Back to mutating the
caller-owned, process-lifetime config in place.
Verified: full ASAN build (all components), meson test suite 9/9 OK,
manual scheduler testsuite 15/15, Go build/vet/test all pass.
Cross-daemon integration test, test-only, spanning the scheduler (C)
and API-server (Go/CGO) receive paths built in tasks 5.1-5.4/5.3b.
Drives real xce_encode()/xce_decode() wire bytes through the actual
production dispatch chain on both daemons -- extract_data() ->
scheduler_req_handler() -> sched_handle_dataset() ->
sched_on_job_array() and go_push_handler() -> handleDataset() ->
cJobArrayToModels()/dispatchJobArray() -- skipping only vtask_entry()'s
~10 lines of static glue, mirrored verbatim in test code (the
if (callnext) treats-any-nonzero-as-proceed behavior that caused
5.3b's bug is reproduced exactly).
Redelivery is tested via genuinely independent fresh decodes of
separately-encoded bytes, not a decode-once-call-twice shortcut.
Bug-catching capability proven by temporarily reverting task 5.3b's
fix and confirming both the C and Go tests fail meaningfully, then
restoring to green -- confirmed independently by both the implementer
and reviewer.
Task 5.5 of job-array-single-wal-record (Requirements 6.1-6.5, 8.4).
This completes Phase 5 (Compact Scheduler/API-Server Push).
Adds the Go/CGO receive path for Data.jobArray, mirroring task 5.3's
scheduler-side C implementation: rawJobArrayToModels() (pure-Go
base_jobid + i expansion), cJobArrayToModels() (CGO adapter sibling to
cJobToModel), dispatchJobArray() (dedup via the same store.Jobs.Has()
the ordinary Data_data_jobs path already writes through, single
confirmation log only when at least one element was newly
dispatched), and a new case C.Data_data_jobArray in handleDataset.
Elements route through OnJobUpdated, matching the ordinary single-job
push's own routing. The CGO boundary needed no changes -- both
C.xce_job_submit_t and C.Data_data_jobArray were already exposed from
prior tasks, including task 5.3b's extract_data() fix which this
receive path depends on identically to the scheduler's.
store.ArrayJobs (the array-level rollup aggregate) is deliberately
left unpopulated -- confirmed out of this spec's scope, deferred to a
future spec per requirements.md's Boundary Context.
Task 5.4 of job-array-single-wal-record (Requirements 6.2, 6.4, 6.5, 8.4).
When a worker re-joins and already exists in workermgr, makeWorker_()
now overrides the existing slot instead of returning EEXIST. Admin-set
flags (LOCKEDU, CLOSED, HIDDEN, CLOSED_BY_ADMIN) survive the override.
Add XCE_WORKER_CLOSED_BY_ADMIN flag to distinguish admin-closed hosts
from workload-closed ones. worker_ctrl sets/clears it alongside CLOSED.
Refactor worker_init/worker_destroy into copy_worker/kill_worker helpers
shared by both fresh creation and override paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three fixes:
1. ExecHosts.UnmarshalJSON: JSON null (sent when no allocation exists)
was treated as a non-"-" string because json.Unmarshal(null, &str)
returns nil with str="". This created a phantom HostSlot{NumSlots:1,
HostName:""} producing "<1*>" for every PEND job. Now null and ""
are both treated as empty hosts.
2. page_print_jobs.go: added !startTime.IsZero() guard so a zero
timestamp ("0001-01-01T00:00:00Z") never produces a start line
even if the status check is bypassed.
3. Added PENDING_REASON field to JobFieldsMap so it is requested from
the API when -l is passed, and rendered in printJobsVerboseOutput
for PEND/PSUSP jobs before the start-time section.
- parsePeer: use net.SplitHostPort (handles IPv6, stdlib convention)
- tryConnect: take Go strings, own CGO allocation internally
- RegisterToBackend: remove unreachable 'no HA candidates' error path
- Sync Peers field comment between utils and backend config structs