@kici-dev/agent 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -12,7 +12,7 @@ import winston from "winston";
12
12
  import { RingBuffer, addLogsToArchive, chunkBuffer, createHealthRoutes, createLogger, createMeter, createMetricsRoutes, deriveSharedSecret, getPrometheusExporter, getReconnectDelay, getRequestContext, guardStartup, initTelemetry, logger, normalizeLineEndings, redactConfig, requestContext, setServiceName, setupGracefulShutdown, sha256, sha256File, toErrorMessage, validateRequiredTools } from "@kici-dev/shared";
13
13
  import { z } from "zod";
14
14
  import { LOGGER_ENV_VARS, defineEnv, validateUnknownKiciVars } from "@kici-dev/shared/env";
15
- import { ALLOWED_SYSTEM_VARS, CacheRunEventType, CacheStepType, ExecutionJobStatus, ExecutionStepStatus, InitFailureCategory, KNOWN_ROLES, PROTOCOL_VERSION, SANDBOX_DEFAULT_VARS, WS_CLOSE_AGENT_AUTH_FAILED, WS_MAX_PAYLOAD_BYTES, applyIncludeExclude, deriveOsArchLabels, expandMatrix, heartbeatSchema, hostLabel, mergeAutoLabels, orchestratorToAgentMessageSchema, parseHostPropertyAssignments, resolveRoleLabels, validateNoReservedLabels } from "@kici-dev/engine";
15
+ import { ALLOWED_SYSTEM_VARS, CacheRunEventType, CacheStepType, ExecutionJobStatus, ExecutionStepStatus, InitFailureCategory, KNOWN_ROLES, PROTOCOL_VERSION, SANDBOX_DEFAULT_VARS, WS_CLOSE_AGENT_AUTH_FAILED, WS_MAX_PAYLOAD_BYTES, applyIncludeExclude, deriveOsArchLabels, expandMatrix, heartbeatSchema, hostLabel, mergeAutoLabels, orchestratorToAgentMessageSchema, parseHostPropertyAssignments, resolveRoleLabels, resolveWhenToRunOn, validateNoReservedLabels } from "@kici-dev/engine";
16
16
  import { execFile, execFileSync, execSync, fork, spawn } from "node:child_process";
17
17
  import WebSocket from "ws";
18
18
  import * as fs$2 from "node:fs";
@@ -102,7 +102,8 @@ const envDef = defineEnv({
102
102
  scalerPendingDispatchTimeoutMs: z.coerce.number().default(6e4),
103
103
  executionMode: ExecutionMode.optional(),
104
104
  otelExporterOtlpEndpoint: z.string().optional(),
105
- concurrencyWaitTimeoutMs: z.coerce.number().int().min(1e3).default(36e5)
105
+ concurrencyWaitTimeoutMs: z.coerce.number().int().min(1e3).default(36e5),
106
+ isOrchestratorHost: z.string().optional().transform((v) => v === "true")
106
107
  }),
107
108
  envMap: {
108
109
  orchestratorUrl: "KICI_ORCHESTRATOR_URL",
@@ -113,6 +114,7 @@ const envDef = defineEnv({
113
114
  port: "KICI_PORT",
114
115
  logLevel: "KICI_LOG_LEVEL",
115
116
  agentToken: "KICI_AGENT_TOKEN",
117
+ isOrchestratorHost: "KICI_AGENT_IS_ORCHESTRATOR_HOST",
116
118
  githubToken: "KICI_GITHUB_TOKEN",
117
119
  maxLogSizeBytes: "KICI_MAX_LOG_SIZE_BYTES",
118
120
  defaultStepTimeoutMs: "KICI_DEFAULT_STEP_TIMEOUT_MS",
@@ -286,7 +288,7 @@ var LogBuffer = class extends RingBuffer {
286
288
  };
287
289
  //#endregion
288
290
  //#region src/ws/orchestrator-client.ts
289
- const logger$11 = createLogger({ prefix: "orchestrator-client" });
291
+ const logger$12 = createLogger({ prefix: "orchestrator-client" });
290
292
  /**
291
293
  * WebSocket client that connects the agent to the customer orchestrator.
292
294
  *
@@ -403,7 +405,7 @@ var OrchestratorClient = class OrchestratorClient {
403
405
  */
404
406
  connect() {
405
407
  if (this._state !== "disconnected") {
406
- logger$11.warn("connect() called while not disconnected", { state: this._state });
408
+ logger$12.warn("connect() called while not disconnected", { state: this._state });
407
409
  return;
408
410
  }
409
411
  this.intentionalDisconnect = false;
@@ -754,7 +756,8 @@ var OrchestratorClient = class OrchestratorClient {
754
756
  stepName: request.stepName,
755
757
  clauses: request.clauses,
756
758
  reason: request.reason,
757
- ...request.timeoutSeconds !== void 0 && { timeoutSeconds: request.timeoutSeconds }
759
+ ...request.timeoutSeconds !== void 0 && { timeoutSeconds: request.timeoutSeconds },
760
+ ...request.payload !== void 0 && { payload: request.payload }
758
761
  });
759
762
  });
760
763
  }
@@ -840,7 +843,7 @@ var OrchestratorClient = class OrchestratorClient {
840
843
  }
841
844
  });
842
845
  } catch (err) {
843
- logger$11.error("Failed to create WebSocket", { error: toErrorMessage(err) });
846
+ logger$12.error("Failed to create WebSocket", { error: toErrorMessage(err) });
844
847
  this._state = "disconnected";
845
848
  this.scheduleReconnect();
846
849
  return;
@@ -848,7 +851,7 @@ var OrchestratorClient = class OrchestratorClient {
848
851
  this.ws.on("open", () => {
849
852
  if (this.token) {
850
853
  this._state = "authenticating";
851
- logger$11.info("Connected to orchestrator, sending auth.request", {
854
+ logger$12.info("Connected to orchestrator, sending auth.request", {
852
855
  url: this.url,
853
856
  agentId: this.agentId
854
857
  });
@@ -859,7 +862,7 @@ var OrchestratorClient = class OrchestratorClient {
859
862
  }));
860
863
  } else {
861
864
  this._state = "registering";
862
- logger$11.info("Connected to orchestrator, sending agent.register (no token)", {
865
+ logger$12.info("Connected to orchestrator, sending agent.register (no token)", {
863
866
  url: this.url,
864
867
  agentId: this.agentId
865
868
  });
@@ -870,12 +873,12 @@ var OrchestratorClient = class OrchestratorClient {
870
873
  this.handleMessage(data);
871
874
  });
872
875
  this.ws.on("close", (code, reason) => {
873
- logger$11.info("Orchestrator connection closed", {
876
+ logger$12.info("Orchestrator connection closed", {
874
877
  code,
875
878
  reason: reason.toString()
876
879
  });
877
880
  if (code === WS_CLOSE_AGENT_AUTH_FAILED) {
878
- logger$11.error("Orchestrator closed with auth-failed code -- token is invalid or revoked. NOT retrying.", {
881
+ logger$12.error("Orchestrator closed with auth-failed code -- token is invalid or revoked. NOT retrying.", {
879
882
  code,
880
883
  reason: reason.toString()
881
884
  });
@@ -901,7 +904,7 @@ var OrchestratorClient = class OrchestratorClient {
901
904
  if (!this.intentionalDisconnect) this.scheduleReconnect();
902
905
  });
903
906
  this.ws.on("error", (err) => {
904
- logger$11.error(`Orchestrator WebSocket error: ${err.message}`);
907
+ logger$12.error(`Orchestrator WebSocket error: ${err.message}`);
905
908
  if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.close();
906
909
  });
907
910
  }
@@ -910,7 +913,7 @@ var OrchestratorClient = class OrchestratorClient {
910
913
  try {
911
914
  raw = JSON.parse(data.toString());
912
915
  } catch {
913
- logger$11.warn("Malformed JSON received from orchestrator");
916
+ logger$12.warn("Malformed JSON received from orchestrator");
914
917
  return;
915
918
  }
916
919
  const rawMsg = raw;
@@ -968,13 +971,13 @@ var OrchestratorClient = class OrchestratorClient {
968
971
  switch (msg.type) {
969
972
  case "auth.success":
970
973
  if (this._state === "authenticating") {
971
- logger$11.info("Authentication successful, sending agent.register", { connectionId: msg.connectionId });
974
+ logger$12.info("Authentication successful, sending agent.register", { connectionId: msg.connectionId });
972
975
  this._state = "registering";
973
976
  this.sendAgentRegister();
974
977
  }
975
978
  break;
976
979
  case "auth.failure":
977
- logger$11.error("Authentication FAILED -- token is invalid or expired. NOT retrying.", { reason: msg.reason });
980
+ logger$12.error("Authentication FAILED -- token is invalid or expired. NOT retrying.", { reason: msg.reason });
978
981
  this.authFailed = true;
979
982
  this.intentionalDisconnect = true;
980
983
  if (this.ws) {
@@ -984,7 +987,7 @@ var OrchestratorClient = class OrchestratorClient {
984
987
  this._state = "disconnected";
985
988
  break;
986
989
  case "register.ack":
987
- logger$11.info("Registration acknowledged by orchestrator", {
990
+ logger$12.info("Registration acknowledged by orchestrator", {
988
991
  agentId: msg.agentId,
989
992
  labels: msg.labels,
990
993
  scalerManaged: msg.scalerManaged,
@@ -999,14 +1002,14 @@ var OrchestratorClient = class OrchestratorClient {
999
1002
  this.sendConfigAck(msg.agentId);
1000
1003
  break;
1001
1004
  case "job.dispatch":
1002
- logger$11.info("Job dispatch received", {
1005
+ logger$12.info("Job dispatch received", {
1003
1006
  runId: msg.runId,
1004
1007
  jobId: msg.jobId
1005
1008
  });
1006
1009
  this.onJobDispatch(msg);
1007
1010
  break;
1008
1011
  case "job.cancel":
1009
- logger$11.info("Job cancel received", {
1012
+ logger$12.info("Job cancel received", {
1010
1013
  runId: msg.runId,
1011
1014
  jobId: msg.jobId,
1012
1015
  reason: msg.reason
@@ -1014,7 +1017,7 @@ var OrchestratorClient = class OrchestratorClient {
1014
1017
  this.onJobCancel(msg);
1015
1018
  break;
1016
1019
  case "job.concurrency.ack": {
1017
- logger$11.info("Concurrency ack received", {
1020
+ logger$12.info("Concurrency ack received", {
1018
1021
  requestId: msg.requestId,
1019
1022
  action: msg.action
1020
1023
  });
@@ -1029,7 +1032,7 @@ var OrchestratorClient = class OrchestratorClient {
1029
1032
  break;
1030
1033
  }
1031
1034
  case "step.approval-resolved": {
1032
- logger$11.info("Step approval resolved", {
1035
+ logger$12.info("Step approval resolved", {
1033
1036
  requestId: msg.requestId,
1034
1037
  runId: msg.runId,
1035
1038
  jobId: msg.jobId,
@@ -1049,7 +1052,7 @@ var OrchestratorClient = class OrchestratorClient {
1049
1052
  break;
1050
1053
  }
1051
1054
  case "fleet.logs.request":
1052
- logger$11.info("Fleet log collection requested", {
1055
+ logger$12.info("Fleet log collection requested", {
1053
1056
  requestId: msg.requestId,
1054
1057
  logWindowHours: msg.logWindowHours
1055
1058
  });
@@ -1059,7 +1062,7 @@ var OrchestratorClient = class OrchestratorClient {
1059
1062
  return;
1060
1063
  }
1061
1064
  if (heartbeatSchema.safeParse(raw).success) return;
1062
- logger$11.warn("Invalid message from orchestrator", { errors: parsed.error.issues });
1065
+ logger$12.warn("Invalid message from orchestrator", { errors: parsed.error.issues });
1063
1066
  }
1064
1067
  flushBuffer() {
1065
1068
  const events = this.eventBuffer.flush();
@@ -1073,11 +1076,11 @@ var OrchestratorClient = class OrchestratorClient {
1073
1076
  }
1074
1077
  this.disconnectedAt = null;
1075
1078
  if (events.length > 0) {
1076
- logger$11.info("Flushing event buffer", { count: events.length });
1079
+ logger$12.info("Flushing event buffer", { count: events.length });
1077
1080
  for (const msg of events) this.sendDirect(msg);
1078
1081
  }
1079
1082
  if (logLines.length > 0) {
1080
- logger$11.info("Flushing log buffer", { count: logLines.length });
1083
+ logger$12.info("Flushing log buffer", { count: logLines.length });
1081
1084
  for (let i = 0; i < logLines.length; i += OrchestratorClient.LOG_BATCH_SIZE) {
1082
1085
  const batch = logLines.slice(i, i + OrchestratorClient.LOG_BATCH_SIZE);
1083
1086
  this.sendAgentLogMessage(batch);
@@ -1130,14 +1133,14 @@ var OrchestratorClient = class OrchestratorClient {
1130
1133
  */
1131
1134
  blockMmdsAccess() {
1132
1135
  if (process.getuid?.() !== 0) {
1133
- logger$11.info("MMDS iptables block skipped (non-root) — network isolation handled by orchestrator");
1136
+ logger$12.info("MMDS iptables block skipped (non-root) — network isolation handled by orchestrator");
1134
1137
  return;
1135
1138
  }
1136
1139
  try {
1137
1140
  execSync("iptables -A OUTPUT -d 169.254.169.254 -j DROP", { timeout: 5e3 });
1138
- logger$11.info("MMDS access blocked via iptables");
1141
+ logger$12.info("MMDS access blocked via iptables");
1139
1142
  } catch (err) {
1140
- logger$11.warn("Failed to block MMDS access via iptables", { error: toErrorMessage(err) });
1143
+ logger$12.warn("Failed to block MMDS access via iptables", { error: toErrorMessage(err) });
1141
1144
  }
1142
1145
  }
1143
1146
  /**
@@ -1151,7 +1154,7 @@ var OrchestratorClient = class OrchestratorClient {
1151
1154
  messageId: `config-ack-${agentId}-${Date.now()}`,
1152
1155
  agentId
1153
1156
  }));
1154
- logger$11.info("Config ACK sent to orchestrator", { agentId });
1157
+ logger$12.info("Config ACK sent to orchestrator", { agentId });
1155
1158
  }
1156
1159
  }
1157
1160
  startHeartbeat() {
@@ -1217,12 +1220,12 @@ var OrchestratorClient = class OrchestratorClient {
1217
1220
  scheduleReconnect() {
1218
1221
  this.cancelReconnect();
1219
1222
  if (this.authFailed) {
1220
- logger$11.error("Not reconnecting: authentication permanently failed");
1223
+ logger$12.error("Not reconnecting: authentication permanently failed");
1221
1224
  return;
1222
1225
  }
1223
1226
  const delay = this.getReconnectDelay();
1224
1227
  this.reconnectAttempts++;
1225
- logger$11.info("Scheduling reconnect", {
1228
+ logger$12.info("Scheduling reconnect", {
1226
1229
  attempt: this.reconnectAttempts,
1227
1230
  delayMs: Math.round(delay)
1228
1231
  });
@@ -1307,14 +1310,14 @@ var init_console_capture = __esmMin((() => {
1307
1310
  init_console_capture();
1308
1311
  function safe(name, fallback = "unknown") {
1309
1312
  switch (name) {
1310
- case "version": return "0.1.21";
1311
- case "buildCommit": return "8aeccc2c4";
1312
- case "sdkVersion": return "0.1.21";
1313
- case "sdkBundleHash": return "8308089347c304e41b457d3867b17bbff11d6b5cd9706b6823e7abdbd849f33f";
1314
- case "sharedVersion": return "0.1.21";
1313
+ case "version": return "0.1.23";
1314
+ case "buildCommit": return "4465935bb";
1315
+ case "sdkVersion": return "0.1.23";
1316
+ case "sdkBundleHash": return "3a83610d2d122b9f9b0f924b225a47050d0b35f00a8f3256a3e15f3ff3bf7ddc";
1317
+ case "sharedVersion": return "0.1.23";
1315
1318
  case "sharedBundleHash": return "991385c024392c395d3eb8a68946ef8ed3fcba96f2652f21c3164a54eafa1b1b";
1316
- case "engineVersion": return "0.1.21";
1317
- case "engineBundleHash": return "1b1f49acbbb66f045cfa8284406fce48de8975d9ec3a5685aff70313cd8232cc";
1319
+ case "engineVersion": return "0.1.23";
1320
+ case "engineBundleHash": return "4a0566c709180a6a8744ff3281640e596b8e661ddceb81327cd37d232d9768d5";
1318
1321
  default: return fallback;
1319
1322
  }
1320
1323
  }
@@ -1571,6 +1574,83 @@ async function gcStaleAgentTmpDirs(base = tmpdir()) {
1571
1574
  })];
1572
1575
  }
1573
1576
  //#endregion
1577
+ //#region src/execution/reboot.ts
1578
+ /**
1579
+ * Cross-OS host reboot for the workflow-level `restartHost()` step.
1580
+ *
1581
+ * The agent runs ON the host it executes jobs for, so a `restartHost()` step
1582
+ * reboots that host. `rebootCommandFor` is the pure OS→command mapping (kept
1583
+ * pure for unit-testing); `issueReboot` spawns it detached with a tiny grace so
1584
+ * the job's final flush completes before the box goes down.
1585
+ *
1586
+ * Rebooting needs host privilege (root/admin). If the primitive is denied, the
1587
+ * spawn fails and the caller clears the orchestrator's reboot-pending flag and
1588
+ * surfaces the error — the deadline sweep is the backstop.
1589
+ */
1590
+ const logger$11 = createLogger({ prefix: "reboot" });
1591
+ /** The OS reboot primitive for a Node platform string. */
1592
+ function rebootCommandFor(platform) {
1593
+ switch (platform) {
1594
+ case "darwin": return {
1595
+ cmd: "shutdown",
1596
+ args: ["-r", "now"]
1597
+ };
1598
+ case "win32": return {
1599
+ cmd: "shutdown",
1600
+ args: [
1601
+ "/r",
1602
+ "/t",
1603
+ "0"
1604
+ ]
1605
+ };
1606
+ default: return {
1607
+ cmd: "systemctl",
1608
+ args: ["reboot"]
1609
+ };
1610
+ }
1611
+ }
1612
+ /**
1613
+ * Issue the OS reboot detached. Resolves once the child has been spawned (the
1614
+ * box is going down; there is nothing to await). Rejects synchronously if the
1615
+ * spawn itself fails (e.g. the binary is missing). A privilege denial usually
1616
+ * surfaces as a non-zero exit AFTER spawn — logged, not thrown, because by then
1617
+ * the box may already be on its way down.
1618
+ */
1619
+ function issueReboot(platform = process.platform) {
1620
+ const { cmd, args } = rebootCommandFor(platform);
1621
+ return new Promise((resolve, reject) => {
1622
+ try {
1623
+ const child = spawn(cmd, args, {
1624
+ detached: true,
1625
+ stdio: "ignore"
1626
+ });
1627
+ child.on("error", (err) => {
1628
+ logger$11.error("Reboot command failed to spawn", {
1629
+ cmd,
1630
+ args,
1631
+ error: String(err)
1632
+ });
1633
+ reject(err);
1634
+ });
1635
+ child.on("exit", (code) => {
1636
+ if (code !== 0 && code !== null) logger$11.warn("Reboot command exited non-zero (privilege denied?)", {
1637
+ cmd,
1638
+ args,
1639
+ code
1640
+ });
1641
+ });
1642
+ child.unref();
1643
+ logger$11.info("Issued host reboot", {
1644
+ cmd,
1645
+ args
1646
+ });
1647
+ setImmediate(resolve);
1648
+ } catch (err) {
1649
+ reject(err);
1650
+ }
1651
+ });
1652
+ }
1653
+ //#endregion
1574
1654
  //#region src/metrics/prometheus.ts
1575
1655
  var prometheus_exports = /* @__PURE__ */ __exportAll({
1576
1656
  cloneDurationSeconds: () => cloneDurationSeconds,
@@ -2000,8 +2080,8 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
2000
2080
  }
2001
2081
  var AGENT_SDK_VERSION, AGENT_SDK_BUNDLE_HASH, hookRegistered;
2002
2082
  var init_workflow_loader = __esmMin((() => {
2003
- AGENT_SDK_VERSION = "0.1.21";
2004
- AGENT_SDK_BUNDLE_HASH = "8308089347c304e41b457d3867b17bbff11d6b5cd9706b6823e7abdbd849f33f";
2083
+ AGENT_SDK_VERSION = "0.1.23";
2084
+ AGENT_SDK_BUNDLE_HASH = "3a83610d2d122b9f9b0f924b225a47050d0b35f00a8f3256a3e15f3ff3bf7ddc";
2005
2085
  hookRegistered = false;
2006
2086
  }));
2007
2087
  //#endregion
@@ -2486,6 +2566,274 @@ var init_init_runner = __esmMin((() => {
2486
2566
  init_timeout_util();
2487
2567
  }));
2488
2568
  //#endregion
2569
+ //#region src/bootstrap/ssh-exec.ts
2570
+ /**
2571
+ * Agent-side SSH helper for bootstrap bring-up.
2572
+ *
2573
+ * Lifts the discipline from `infra/prod/hw/ssh.sh` + the deploy-prod
2574
+ * `runOnBox` helper into the agent: the bring-up private key is piped into an
2575
+ * **ephemeral ssh-agent** (`ssh-add -` reads it from stdin) and the agent is
2576
+ * torn down in a `finally`, so the key never lands on disk and never enters a
2577
+ * long-lived process environment. The agent only holds the key for the
2578
+ * lifetime of the one `ssh` / `scp` invocation.
2579
+ *
2580
+ * `sshExec` runs a remote command; `sshPush` ships local bytes to a remote
2581
+ * path (`ssh 'cat > path'`). Both accept a resolved private key string
2582
+ * (supplied by the caller — the orchestrator resolves the scoped secret and
2583
+ * hands the key down). `sshExec` supports `{ stdin, port, hostKeyMode }` so it
2584
+ * can also drive a pre-boot dropbear / initramfs prompt (a forced-command
2585
+ * endpoint such as `cryptroot-unlock` on port 2222, which accepts the unlock
2586
+ * input on stdin and uses a transient host key distinct from the OS sshd key).
2587
+ */
2588
+ /** Common `-o` flags every bootstrap SSH connection carries. */
2589
+ function baseSshOptions(hostKeyMode) {
2590
+ return [
2591
+ "-o",
2592
+ `StrictHostKeyChecking=${HOST_KEY_FLAG[hostKeyMode]}`,
2593
+ "-o",
2594
+ "ConnectTimeout=10",
2595
+ "-o",
2596
+ "BatchMode=yes"
2597
+ ];
2598
+ }
2599
+ /** Resolve the `user@address` target + port from reach metadata. */
2600
+ function resolveTarget(reach, portOverride) {
2601
+ if (!reach.address) throw new Error(`host ${reach.agentId} has no SSH reach address declared`);
2602
+ const user = reach.sshUser ?? SSH_USER_DEFAULT;
2603
+ const port = portOverride ?? reach.sshPort ?? SSH_PORT_DEFAULT;
2604
+ return {
2605
+ dest: `${user}@${reach.address}`,
2606
+ port
2607
+ };
2608
+ }
2609
+ /**
2610
+ * Run a command on the target over SSH using an ephemeral, in-memory key.
2611
+ *
2612
+ * The key is loaded into a per-call ssh-agent (never written to disk) and the
2613
+ * agent is killed in `finally`. The remote command's exit code / stdout /
2614
+ * stderr are returned verbatim — a non-zero exit is reported, not swallowed
2615
+ * (a pre-boot unlock legitimately drops the session, so the caller decides
2616
+ * what a "success" looks like).
2617
+ */
2618
+ async function sshExec(reach, privateKey, command, opts = {}, deps = {}) {
2619
+ const spawnFn = deps.spawnFn ?? defaultSpawn;
2620
+ const hostKeyMode = opts.hostKeyMode ?? "accept-new";
2621
+ const { dest, port } = resolveTarget(reach, opts.port);
2622
+ return withEphemeralAgent(privateKey, spawnFn, async (env) => {
2623
+ return spawnFn("ssh", [
2624
+ ...baseSshOptions(hostKeyMode),
2625
+ "-p",
2626
+ String(port),
2627
+ dest,
2628
+ command
2629
+ ], {
2630
+ env,
2631
+ stdin: opts.stdin
2632
+ });
2633
+ });
2634
+ }
2635
+ /**
2636
+ * Ship local bytes to a remote path over SSH (`ssh 'cat > path'`), using the
2637
+ * same ephemeral-key discipline. Throws on a non-zero exit (a push must
2638
+ * succeed end-to-end, unlike a pre-boot unlock).
2639
+ */
2640
+ async function sshPush(reach, privateKey, localBytes, remotePath, opts = {}, deps = {}) {
2641
+ const spawnFn = deps.spawnFn ?? defaultSpawn;
2642
+ const hostKeyMode = opts.hostKeyMode ?? "accept-new";
2643
+ const { dest, port } = resolveTarget(reach, opts.port);
2644
+ const result = await withEphemeralAgent(privateKey, spawnFn, async (env) => {
2645
+ return spawnFn("ssh", [
2646
+ ...baseSshOptions(hostKeyMode),
2647
+ "-p",
2648
+ String(port),
2649
+ dest,
2650
+ `cat > '${remotePath.replace(/'/g, `'\\''`)}'`
2651
+ ], {
2652
+ env,
2653
+ stdin: localBytes
2654
+ });
2655
+ });
2656
+ if (result.exitCode !== 0) throw new Error(`sshPush(${reach.agentId}:${remotePath}): exit ${result.exitCode}${result.stderr ? `\n${result.stderr}` : ""}`);
2657
+ }
2658
+ /**
2659
+ * Start a per-call ephemeral ssh-agent, load the key via stdin (never a file),
2660
+ * run `body` with `SSH_AUTH_SOCK` in env, and kill the agent in `finally`.
2661
+ */
2662
+ async function withEphemeralAgent(privateKey, spawnFn, body) {
2663
+ const baseEnv = { ...process.env };
2664
+ const start = await spawnFn("ssh-agent", ["-s"], { env: baseEnv });
2665
+ if (start.exitCode !== 0) throw new Error(`ssh-agent start failed: exit ${start.exitCode}\n${start.stderr}`);
2666
+ const sock = parseAgentSocket(start.stdout);
2667
+ const pid = parseAgentPid(start.stdout);
2668
+ const agentEnv = {
2669
+ ...baseEnv,
2670
+ SSH_AUTH_SOCK: sock,
2671
+ ...pid ? { SSH_AGENT_PID: pid } : {},
2672
+ SSH_ASKPASS: "/bin/false",
2673
+ DISPLAY: ""
2674
+ };
2675
+ try {
2676
+ const add = await spawnFn("ssh-add", ["-"], {
2677
+ env: agentEnv,
2678
+ stdin: privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`
2679
+ });
2680
+ if (add.exitCode !== 0) throw new Error(`ssh-add failed: exit ${add.exitCode}\n${add.stderr}`);
2681
+ return await body(agentEnv);
2682
+ } finally {
2683
+ await spawnFn("ssh-agent", ["-k"], { env: agentEnv }).catch(() => {});
2684
+ }
2685
+ }
2686
+ /** Extract `SSH_AUTH_SOCK=<path>;` from `ssh-agent -s` output. */
2687
+ function parseAgentSocket(out) {
2688
+ const m = out.match(/SSH_AUTH_SOCK=([^;\n]+)/);
2689
+ if (!m) throw new Error("ssh-agent -s did not emit SSH_AUTH_SOCK");
2690
+ return m[1];
2691
+ }
2692
+ /** Extract `SSH_AGENT_PID=<n>;` from `ssh-agent -s` output (best-effort). */
2693
+ function parseAgentPid(out) {
2694
+ return out.match(/SSH_AGENT_PID=([^;\n]+)/)?.[1];
2695
+ }
2696
+ var defaultSpawn, SSH_USER_DEFAULT, SSH_PORT_DEFAULT, HOST_KEY_FLAG;
2697
+ var init_ssh_exec = __esmMin((() => {
2698
+ defaultSpawn = (command, args, opts) => new Promise((resolve, reject) => {
2699
+ const child = spawn(command, args, {
2700
+ env: opts.env,
2701
+ stdio: [
2702
+ opts.stdin !== void 0 ? "pipe" : "ignore",
2703
+ "pipe",
2704
+ "pipe"
2705
+ ]
2706
+ });
2707
+ let stdout = "";
2708
+ let stderr = "";
2709
+ child.stdout?.on("data", (b) => {
2710
+ stdout += b.toString();
2711
+ });
2712
+ child.stderr?.on("data", (b) => {
2713
+ stderr += b.toString();
2714
+ });
2715
+ child.on("error", reject);
2716
+ child.on("close", (code) => resolve({
2717
+ exitCode: code ?? -1,
2718
+ stdout,
2719
+ stderr
2720
+ }));
2721
+ if (opts.stdin !== void 0) child.stdin?.end(opts.stdin);
2722
+ });
2723
+ SSH_USER_DEFAULT = "root";
2724
+ SSH_PORT_DEFAULT = 22;
2725
+ HOST_KEY_FLAG = {
2726
+ "accept-new": "accept-new",
2727
+ strict: "yes"
2728
+ };
2729
+ }));
2730
+ //#endregion
2731
+ //#region src/bootstrap/ensure-init-runner.ts
2732
+ /**
2733
+ * Build the launcher script that starts the init-runner on the target with its
2734
+ * bootstrap env. Detached (`setsid … &`) so the SSH session can return while
2735
+ * the agent keeps running and dials the orchestrator.
2736
+ */
2737
+ function buildLauncher(material, agentCommand) {
2738
+ return [
2739
+ "#!/usr/bin/env bash",
2740
+ "set -euo pipefail",
2741
+ `setsid env ${[
2742
+ `KICI_AGENT_TOKEN=${shQuote(material.bootstrapToken)}`,
2743
+ `KICI_AGENT_ID=${shQuote(material.targetAgentId)}`,
2744
+ `KICI_ORCHESTRATOR_URL=${shQuote(material.orchestratorUrl)}`,
2745
+ `KICI_LABELS=${shQuote(material.labels.join(","))}`,
2746
+ "KICI_EXECUTION_MODE=bare-metal",
2747
+ "KICI_PORT=0"
2748
+ ].join(" \\\n ")} \\`,
2749
+ ` ${agentCommand} >/tmp/kici-init-runner.log 2>&1 &`,
2750
+ "echo \"init-runner started pid=$!\""
2751
+ ].join("\n");
2752
+ }
2753
+ /** Single-quote a value for safe embedding in the launcher's env assignment. */
2754
+ function shQuote(v) {
2755
+ return `'${v.replace(/'/g, `'\\''`)}'`;
2756
+ }
2757
+ /**
2758
+ * Bring up a temporary init-runner on `targetAgentId`. Returns `{ broughtUp }`:
2759
+ * false when the target already had a live agent (the orchestrator no-op'd),
2760
+ * true when this call dropped + started the init-runner.
2761
+ */
2762
+ async function ensureInitRunner(transport, targetAgentId, deps = {}) {
2763
+ const material = await transport("kici.ensureInitRunner", { targetAgentId });
2764
+ if (!material.broughtUp) return { broughtUp: false };
2765
+ const { reach, privateKey, bootstrapToken, orchestratorUrl, labels } = material;
2766
+ if (!reach || !privateKey || !bootstrapToken || !orchestratorUrl || !labels) throw new Error(`orchestrator returned incomplete bring-up material for ${targetAgentId}`);
2767
+ const agentCommand = deps.agentCommand ?? DEFAULT_AGENT_COMMAND;
2768
+ await sshPush(reach, privateKey, buildLauncher({
2769
+ bootstrapToken,
2770
+ targetAgentId,
2771
+ orchestratorUrl,
2772
+ labels
2773
+ }, agentCommand), LAUNCHER_REMOTE_PATH, {}, deps);
2774
+ const run = await sshExec(reach, privateKey, `chmod 0700 ${LAUNCHER_REMOTE_PATH} && ${LAUNCHER_REMOTE_PATH}`, {}, deps);
2775
+ if (run.exitCode !== 0) throw new Error(`init-runner launch on ${targetAgentId} failed: exit ${run.exitCode}${run.stderr ? `\n${run.stderr}` : ""}`);
2776
+ return { broughtUp: true };
2777
+ }
2778
+ var DEFAULT_AGENT_COMMAND, LAUNCHER_REMOTE_PATH;
2779
+ var init_ensure_init_runner = __esmMin((() => {
2780
+ init_ssh_exec();
2781
+ DEFAULT_AGENT_COMMAND = "kici-agent";
2782
+ LAUNCHER_REMOTE_PATH = "/tmp/kici-init-runner.sh";
2783
+ }));
2784
+ //#endregion
2785
+ //#region src/bootstrap/pre-boot-send.ts
2786
+ /**
2787
+ * Ship a pre-boot input to the target's dropbear/initramfs SSH channel. The
2788
+ * input plaintext is resolved server-side and never logged. Resolves once the
2789
+ * send completes (the SSH session legitimately drops as the box boots).
2790
+ */
2791
+ async function preBootSend(transport, targetAgentId, opts, deps = {}) {
2792
+ const material = await transport("kici.preBootSend", {
2793
+ targetAgentId,
2794
+ inputSecret: opts.inputSecret,
2795
+ ...opts.port !== void 0 ? { port: opts.port } : {},
2796
+ ...opts.command !== void 0 ? { command: opts.command } : {}
2797
+ });
2798
+ await sshExec(material.reach, material.privateKey, material.command, {
2799
+ stdin: material.input,
2800
+ port: material.port,
2801
+ hostKeyMode: "accept-new"
2802
+ }, deps);
2803
+ }
2804
+ var init_pre_boot_send = __esmMin((() => {
2805
+ init_ssh_exec();
2806
+ }));
2807
+ //#endregion
2808
+ //#region src/bootstrap/api-intercept.ts
2809
+ /**
2810
+ * Wrap the orchestrator API transport so the two bootstrap methods are handled
2811
+ * in-process (SSH transport here; privileged resolve relayed to the
2812
+ * orchestrator). `relay` is the raw orchestrator transport (the WS
2813
+ * `sendApiRequest`).
2814
+ */
2815
+ function withBootstrapInterception(relay, deps = {}) {
2816
+ return async (method, params = {}) => {
2817
+ if (method === ENSURE_INIT_RUNNER) return ensureInitRunner(relay, String(params.targetAgentId ?? ""), deps);
2818
+ if (method === PRE_BOOT_SEND) {
2819
+ await preBootSend(relay, String(params.targetAgentId ?? ""), {
2820
+ inputSecret: String(params.inputSecret ?? ""),
2821
+ ...typeof params.port === "number" ? { port: params.port } : {},
2822
+ ...typeof params.command === "string" ? { command: params.command } : {}
2823
+ }, deps);
2824
+ return;
2825
+ }
2826
+ return relay(method, params);
2827
+ };
2828
+ }
2829
+ var ENSURE_INIT_RUNNER, PRE_BOOT_SEND;
2830
+ var init_api_intercept = __esmMin((() => {
2831
+ init_ensure_init_runner();
2832
+ init_pre_boot_send();
2833
+ ENSURE_INIT_RUNNER = "kici.ensureInitRunner";
2834
+ PRE_BOOT_SEND = "kici.preBootSend";
2835
+ }));
2836
+ //#endregion
2489
2837
  //#region src/execution/dynamic-job-serializer.ts
2490
2838
  /**
2491
2839
  * Convert an array of SDK Job objects into LockJob format for the orchestrator.
@@ -2550,7 +2898,8 @@ async function serializeJob(job, generatedNames, ctx, staticNames, allowedGroups
2550
2898
  * Validates against generatedNames union staticNames union allowedGroups.
2551
2899
  *
2552
2900
  * Returns the lock file representation: strings for concrete refs,
2553
- * NeedsEntry for { name, ifFailed }, NeedsGroupEntry for group refs.
2901
+ * NeedsEntry for { name, when }, NeedsGroupEntry for group refs (each `when`
2902
+ * normalized to a runOn status-set).
2554
2903
  */
2555
2904
  function resolveNeeds(needs, generatedNames, staticNames, allowedGroups) {
2556
2905
  if (!needs || needs.length === 0) return [];
@@ -2565,7 +2914,7 @@ function resolveNeeds(needs, generatedNames, staticNames, allowedGroups) {
2565
2914
  if (!allowedGroups.has(groupRef.group)) throw new Error(`Dynamic group '${groupRef.group}' not found in workflow (available groups: ${[...allowedGroups].join(", ") || "none"})`);
2566
2915
  return {
2567
2916
  group: groupRef.group,
2568
- ifFailed: groupRef.ifFailed ?? "skip"
2917
+ runOn: resolveWhenToRunOn(groupRef.when)
2569
2918
  };
2570
2919
  }
2571
2920
  if (typeof dep === "object" && dep !== null && "group" in dep) {
@@ -2573,7 +2922,7 @@ function resolveNeeds(needs, generatedNames, staticNames, allowedGroups) {
2573
2922
  if (!allowedGroups.has(groupDep.group)) throw new Error(`Dynamic group '${groupDep.group}' not found in workflow (available groups: ${[...allowedGroups].join(", ") || "none"})`);
2574
2923
  return {
2575
2924
  group: groupDep.group,
2576
- ifFailed: groupDep.ifFailed ?? "skip"
2925
+ runOn: resolveWhenToRunOn(groupDep.when)
2577
2926
  };
2578
2927
  }
2579
2928
  if (typeof dep === "object" && dep !== null && "name" in dep && !("steps" in dep)) {
@@ -2581,7 +2930,7 @@ function resolveNeeds(needs, generatedNames, staticNames, allowedGroups) {
2581
2930
  if (!allNames.has(namedDep.name)) throw new Error(`Job dependency '${namedDep.name}' not found in workflow jobs (checked: ${generatedNames.size} generated, ${staticNames.size} static)`);
2582
2931
  return {
2583
2932
  name: namedDep.name,
2584
- ifFailed: namedDep.ifFailed ?? "skip"
2933
+ runOn: resolveWhenToRunOn(namedDep.when)
2585
2934
  };
2586
2935
  }
2587
2936
  if (isDynamicJobFn(dep)) throw new Error("Job dependency cannot be a DynamicJobFn");
@@ -2606,7 +2955,13 @@ function serializeSteps(steps) {
2606
2955
  name: step.name || `step-${index}`,
2607
2956
  hasOutputs: !!step.outputs,
2608
2957
  ...step.continueOnError ? { continueOnError: true } : {},
2609
- ...step.timeout ? { timeout: step.timeout } : {}
2958
+ ...step.timeout ? { timeout: step.timeout } : {},
2959
+ ...step.retry ? { retry: {
2960
+ maxAttempts: step.retry.maxAttempts,
2961
+ delayMs: step.retry.delayMs,
2962
+ backoff: step.retry.backoff,
2963
+ maxDelayMs: step.retry.maxDelayMs
2964
+ } } : {}
2610
2965
  };
2611
2966
  });
2612
2967
  }
@@ -4182,6 +4537,9 @@ function buildRequest(dispatch, workDir) {
4182
4537
  matrixValues: jobConfig.matrixValues,
4183
4538
  host: jobConfig.host,
4184
4539
  agent: jobConfig.agent,
4540
+ dispatchInputs: jobConfig.dispatchInputs,
4541
+ fanoutIndex: jobConfig.fanoutIndex,
4542
+ fanoutTotal: jobConfig.fanoutTotal,
4185
4543
  secrets: dispatch.secrets,
4186
4544
  namespacedSecrets: dispatch.namespacedSecrets,
4187
4545
  sourceFile: jobConfig.source?.file,
@@ -4212,6 +4570,8 @@ function buildRequest(dispatch, workDir) {
4212
4570
  concurrencyEvaluationTimeoutMs: jobConfig.concurrencyEvaluationTimeoutMs,
4213
4571
  branch: dispatch.ref,
4214
4572
  upstreamJobOutputs: dispatch.upstreamJobOutputs,
4573
+ upstreamJobStatuses: dispatch.upstreamJobStatuses,
4574
+ jobNeeds: jobConfig.needs,
4215
4575
  npmRegistries: dispatch.npmRegistries,
4216
4576
  installEnvSecrets: dispatch.installEnvSecrets,
4217
4577
  jobIdShort: dispatch.jobId.slice(0, 8),
@@ -5394,7 +5754,10 @@ var init_sandbox = __esmMin((() => {
5394
5754
  }));
5395
5755
  //#endregion
5396
5756
  //#region src/execution/job-runner.ts
5397
- var job_runner_exports = /* @__PURE__ */ __exportAll({ JobRunner: () => JobRunner$1 });
5757
+ var job_runner_exports = /* @__PURE__ */ __exportAll({
5758
+ JobRunner: () => JobRunner$1,
5759
+ buildEvalNeedsContext: () => buildEvalNeedsContext
5760
+ });
5398
5761
  /**
5399
5762
  * Check if a file exists at the given path.
5400
5763
  */
@@ -5457,6 +5820,8 @@ var init_job_runner = __esmMin((() => {
5457
5820
  init_source_packer();
5458
5821
  init_source_restore();
5459
5822
  init_init_runner();
5823
+ init_api_intercept();
5824
+ init_ensure_init_runner();
5460
5825
  init_timeout_util();
5461
5826
  init_dynamic_job_serializer();
5462
5827
  init_log_streamer();
@@ -5569,6 +5934,10 @@ var init_job_runner = __esmMin((() => {
5569
5934
  await this.handleDynamicJobFn(dispatch, workDir, abortController);
5570
5935
  return true;
5571
5936
  }
5937
+ if (jobConfig.bringupOnly === true) {
5938
+ await this.handleBringupJob(dispatch);
5939
+ return true;
5940
+ }
5572
5941
  if (jobConfig.buildOnly === true) {
5573
5942
  if (jobConfig.fullRepo) {
5574
5943
  logger$2.warn("Build job received for fullRepo run -- skipping (should not happen)", {
@@ -5741,7 +6110,7 @@ var init_job_runner = __esmMin((() => {
5741
6110
  reason: ack.reason
5742
6111
  };
5743
6112
  },
5744
- onApiRequest: this._sendApiRequest ? async (method, params) => this._sendApiRequest(method, params) : void 0,
6113
+ onApiRequest: this._sendApiRequest ? withBootstrapInterception(async (method, params) => this._sendApiRequest(method, params)) : void 0,
5745
6114
  onCacheRequest: this._requestUserCache ? async (request) => this._requestUserCache(jobId, request) : void 0,
5746
6115
  onProvenanceRequest: this._relayProvenance ? async (request) => this._relayProvenance(jobId, request) : void 0,
5747
6116
  onApprovalRequest: this._sendStepApproval ? async (request) => this._sendStepApproval(dispatch.runId, dispatch.jobId, request) : void 0,
@@ -5803,6 +6172,53 @@ var init_job_runner = __esmMin((() => {
5803
6172
  }, result.secretOutputs);
5804
6173
  }
5805
6174
  /**
6175
+ * Handle a bring-up job: bring up a temporary init-runner on a declared-but-
6176
+ * un-agented host over SSH (fresh-box bootstrap convergence). The orchestrator
6177
+ * dispatches this synthetic `__bringup__` job to an agent holding the
6178
+ * `kici:capability:ssh-transport` capability; here we run the agent-side
6179
+ * `ensureInitRunner` helper (the privileged resolve is relayed to the
6180
+ * orchestrator, the SSH transport happens in this agent process — never
6181
+ * reaching workflow code). No clone, no sandbox — the init-runner then
6182
+ * connects under the target's agent id and the orchestrator's pinned-hold
6183
+ * drains the target's bootstrap steps onto it.
6184
+ */
6185
+ async handleBringupJob(dispatch) {
6186
+ const { runId, jobId, jobConfig } = dispatch;
6187
+ const targetAgentId = String(jobConfig.bringupTarget ?? "");
6188
+ logger$2.info("Starting bring-up job", {
6189
+ jobId,
6190
+ runId,
6191
+ targetAgentId
6192
+ });
6193
+ this.sendJobStatus(dispatch, ExecutionJobStatus.enum.running);
6194
+ const streamer = this.createStepStreamer(dispatch, 0);
6195
+ this.sendStepStatus(dispatch, 0, "bring-up", ExecutionStepStatus.enum.running);
6196
+ try {
6197
+ if (!targetAgentId) throw new Error("bring-up job missing bringupTarget");
6198
+ if (!this._sendApiRequest) throw new Error("bring-up job requires an orchestrator API transport");
6199
+ streamer.addLine(`Bringing up init-runner on ${targetAgentId}…`);
6200
+ const result = await ensureInitRunner(async (method, params) => this._sendApiRequest(method, params), targetAgentId);
6201
+ streamer.addLine(result.broughtUp ? `Init-runner brought up on ${targetAgentId}.` : `${targetAgentId} already has a live agent — no bring-up needed.`);
6202
+ await streamer.flush();
6203
+ this.sendStepStatus(dispatch, 0, "bring-up", ExecutionStepStatus.enum.success, void 0, streamer.getTotalBytes());
6204
+ streamer.destroy();
6205
+ this.sendJobStatus(dispatch, ExecutionJobStatus.enum.success);
6206
+ } catch (err) {
6207
+ const message = err instanceof Error ? err.message : String(err);
6208
+ streamer.addLine(`Bring-up failed: ${message}`);
6209
+ await streamer.flush();
6210
+ this.sendStepStatus(dispatch, 0, "bring-up", ExecutionStepStatus.enum.failed, void 0, streamer.getTotalBytes());
6211
+ streamer.destroy();
6212
+ this.sendJobStatus(dispatch, ExecutionJobStatus.enum.failed, { error: message });
6213
+ logger$2.warn("Bring-up job failed", {
6214
+ jobId,
6215
+ runId,
6216
+ targetAgentId,
6217
+ error: message
6218
+ });
6219
+ }
6220
+ }
6221
+ /**
5806
6222
  * Handle a build-only job.
5807
6223
  *
5808
6224
  * Build jobs install dependencies, pack them into a tarball,
@@ -6195,7 +6611,7 @@ var init_job_runner = __esmMin((() => {
6195
6611
  error: (msg, ..._args) => evalLog(`ERROR: ${msg}`),
6196
6612
  debug: (msg, ..._args) => evalLog(`DEBUG: ${msg}`)
6197
6613
  };
6198
- const kici = buildKiciApi(this._sendApiRequest ? (method, params) => this._sendApiRequest(method, params ?? {}) : () => Promise.reject(/* @__PURE__ */ new Error("Agent API not available")));
6614
+ const kici = buildKiciApi(this._sendApiRequest ? withBootstrapInterception((method, params) => this._sendApiRequest(method, params ?? {})) : () => Promise.reject(/* @__PURE__ */ new Error("Agent API not available")));
6199
6615
  const lockJobs = await runCaptured(evalSink, async () => {
6200
6616
  const { module } = await loadWorkflowSource(workDir, config.source.file, config.contentHash, config.resolvedHashFiles);
6201
6617
  evalLog("Workflow loaded");
@@ -6425,14 +6841,14 @@ var init_job_runner = __esmMin((() => {
6425
6841
  */
6426
6842
  init_console_capture();
6427
6843
  init_npm_resolver();
6428
- const AGENT_VERSION = "0.1.21";
6429
- const BUILD_COMMIT = "8aeccc2c4";
6430
- const SDK_VERSION = "0.1.21";
6431
- const SDK_BUNDLE_HASH = "8308089347c304e41b457d3867b17bbff11d6b5cd9706b6823e7abdbd849f33f";
6432
- const SHARED_VERSION = "0.1.21";
6844
+ const AGENT_VERSION = "0.1.23";
6845
+ const BUILD_COMMIT = "4465935bb";
6846
+ const SDK_VERSION = "0.1.23";
6847
+ const SDK_BUNDLE_HASH = "3a83610d2d122b9f9b0f924b225a47050d0b35f00a8f3256a3e15f3ff3bf7ddc";
6848
+ const SHARED_VERSION = "0.1.23";
6433
6849
  const SHARED_BUNDLE_HASH = "991385c024392c395d3eb8a68946ef8ed3fcba96f2652f21c3164a54eafa1b1b";
6434
- const ENGINE_VERSION = "0.1.21";
6435
- const ENGINE_BUNDLE_HASH = "1b1f49acbbb66f045cfa8284406fce48de8975d9ec3a5685aff70313cd8232cc";
6850
+ const ENGINE_VERSION = "0.1.23";
6851
+ const ENGINE_BUNDLE_HASH = "4a0566c709180a6a8744ff3281640e596b8e661ddceb81327cd37d232d9768d5";
6436
6852
  initTelemetry({
6437
6853
  serviceName: "kici-agent",
6438
6854
  otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
@@ -6498,6 +6914,7 @@ await guardStartup(logger$1, async () => {
6498
6914
  }
6499
6915
  let isDraining = false;
6500
6916
  let idleShutdownTimer;
6917
+ let rebootIntent = false;
6501
6918
  let client;
6502
6919
  const jobRunner = new JobRunner({
6503
6920
  send: (msg) => client.send(msg),
@@ -6511,7 +6928,15 @@ await guardStartup(logger$1, async () => {
6511
6928
  sendJobContext: (runId, jobId, context) => client.sendJobContext(runId, jobId, context),
6512
6929
  sendRunEvent: (runId, eventType, opts) => client.sendRunEvent(runId, eventType, opts),
6513
6930
  sendConcurrencyReport: (runId, jobId, group) => client.sendConcurrencyReport(runId, jobId, group),
6514
- sendApiRequest: (method, params) => client.sendApiRequest(method, params ?? {}),
6931
+ sendApiRequest: async (method, params) => {
6932
+ if (method === "host.requestReboot") {
6933
+ if (config.isOrchestratorHost) throw new Error("refusing to reboot the orchestrator host");
6934
+ const result = await client.sendApiRequest(method, params ?? {});
6935
+ rebootIntent = true;
6936
+ return result;
6937
+ }
6938
+ return client.sendApiRequest(method, params ?? {});
6939
+ },
6515
6940
  requestUserCache: (jobId, request) => client.requestUserCache(jobId, request),
6516
6941
  relayProvenance: (jobId, request) => client.relayProvenance(jobId, request),
6517
6942
  sendStepApproval: (runId, jobId, request) => client.sendStepApproval(runId, jobId, request)
@@ -6600,6 +7025,13 @@ await guardStartup(logger$1, async () => {
6600
7025
  jobsActive.add(-1);
6601
7026
  metricsReporter.collectAndSend().catch(() => {});
6602
7027
  sendAgentStatus();
7028
+ if (rebootIntent) {
7029
+ rebootIntent = false;
7030
+ issueReboot().catch((err) => {
7031
+ logger$1.error("Host reboot failed; cancelling reboot-pending flag", { error: toErrorMessage(err) });
7032
+ client.sendApiRequest("host.cancelReboot", {}).catch(() => {});
7033
+ });
7034
+ }
6603
7035
  if (config.scalerManaged && jobRunner.activeJobs.size === 0) {
6604
7036
  if (client.state !== "registered") {
6605
7037
  logger$1.info("Scaler-managed agent idle but disconnected, deferring shutdown until reconnected");