@kici-dev/orchestrator 0.18.0 → 0.19.0

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.
@@ -70,8 +70,16 @@ export interface AgentEntry {
70
70
  * In memory rather than DB-backed, unlike reboot-pending: the destroy is issued by
71
71
  * this process, lasts seconds, and ends with the WebSocket close that removes the
72
72
  * entry — there is nothing to outlive.
73
+ *
74
+ * Also set when the agent itself reports `draining` on `agent.status` (a SIGUSR1
75
+ * drain, or a single-job agent that accepted its job); see {@link drainReported}.
73
76
  */
74
77
  draining: boolean;
78
+ /**
79
+ * The agent reported `draining` itself. Its drain is terminal for this connection,
80
+ * so {@link AgentRegistry.clearDraining} (the warm-pool undo) leaves it draining.
81
+ */
82
+ drainReported?: boolean;
75
83
  /**
76
84
  * Epoch ms until which the agent is held out of routing after it rejected a
77
85
  * dispatch as busy. Unset when no hold is in force. See {@link AgentRegistry.markBusyHeld}.
@@ -337,8 +345,11 @@ export declare class AgentRegistry {
337
345
  */
338
346
  isRegistered(agentId: string): boolean;
339
347
  /**
340
- * Condemn an agent before a warm-pool destroy: it stays registered and finishes its
341
- * current work, but takes no new dispatches.
348
+ * Condemn an agent: it stays registered and keeps finishing its current work,
349
+ * but takes no new dispatches.
350
+ *
351
+ * Called before a warm-pool destroy is issued. An agent that reports its own
352
+ * drain goes through {@link markDrainReported} instead.
342
353
  *
343
354
  * @returns the agent's `activeJobs`, so the caller can see a job claimed between its
344
355
  * idle check and this mark and back out; `undefined` when not registered
@@ -382,7 +393,15 @@ export declare class AgentRegistry {
382
393
  * free slot now.
383
394
  */
384
395
  clearExpiredBusyHold(agentId: string, now?: number): void;
385
- /** Undo {@link markDraining} — a destroy that was rejected leaves the agent alive. */
396
+ /**
397
+ * The agent reported on `agent.status` that it takes no new job: a drain, or a
398
+ * single-job agent that has accepted its job. Terminal for this connection.
399
+ */
400
+ markDrainReported(agentId: string): void;
401
+ /**
402
+ * Undo {@link markDraining} — a destroy that was rejected leaves the agent alive.
403
+ * An agent that reported its own drain stays draining.
404
+ */
386
405
  clearDraining(agentId: string): void;
387
406
  get(agentId: string): AgentEntry | undefined;
388
407
  getByWs(ws: WsLike): AgentEntry | undefined;
@@ -265,13 +265,13 @@ export declare function npmReleaseReadsEnvFile(target: NpmInstallTarget, version
265
265
  error: string;
266
266
  };
267
267
  /**
268
- * Install `<owningPackage>@<version>` globally under the unit's pinned node, so
269
- * it lands in the prefix the unit's ExecStart resolves from. npm derives its
270
- * global prefix from the node that runs npm-cli.js, and the unit's
271
- * `<pinned>/bin/npm` (an `env node` shebang or a version-manager wrapper)
272
- * resolves `node` off PATH; run bare it would inherit the shell's active node,
273
- * the mise/nvm/asdf drift this upgrade fixes. Hence the pinned bin dir goes
274
- * first on PATH ({@link npmEnv}).
268
+ * Install `<owningPackage>@<version>` globally into the prefix the unit's
269
+ * ExecStart resolves from, with the unit's pinned node. Without an explicit
270
+ * prefix npm derives one from the node that runs npm-cli.js, or takes the
271
+ * `npm_config_prefix` the caller's environment carries (`npx` exports its
272
+ * own), so the install would land where the unit never reads it. The pinned
273
+ * node runs npm-cli.js directly, skipping a version-manager wrapper at
274
+ * `<pinned>/bin/npm`, which also keeps the shell's active node out of it.
275
275
  */
276
276
  export declare function installGlobalPackage(target: NpmInstallTarget, version: string, run?: SpawnRunner): {
277
277
  ok: boolean;
@@ -57,16 +57,42 @@ export declare function writeInstallManifest(instanceDir: string, fresh: Instanc
57
57
  * Where an npm-source upgrade installs, from a unit's launch spec.
58
58
  * `owningPackage` is the top-level global package ExecStart runs (`kici-admin`
59
59
  * when the component loads through its nested node_modules, else
60
- * `@kici-dev/<component>`). `npmPath` sits beside the pinned node, so
61
- * `npm install -g` lands in the prefix ExecStart resolves from, whatever the
62
- * shell's active node.
60
+ * `@kici-dev/<component>`). `globalPrefix` is the npm prefix the launch entry
61
+ * lives in; every npm call passes it as `--prefix`, because the caller's
62
+ * environment can name another one (`npx` exports `npm_config_prefix`).
63
+ * `npmInvocation` runs `npm-cli.js` with the pinned node when it sits beside
64
+ * that node, so a version-manager wrapper at `npmPath` (mise's reshim needs
65
+ * mise on PATH) is never started.
63
66
  */
64
67
  export interface NpmInstallTarget {
65
68
  nodeExecPath: string;
66
69
  npmPath: string;
67
70
  owningPackage: string;
71
+ globalPrefix: string;
72
+ npmInvocation: {
73
+ cmd: string;
74
+ preArgs: string[];
75
+ };
68
76
  }
77
+ /**
78
+ * The resolution of a launch spec to an install target. `no-entry` covers an
79
+ * opaque `--binary` install and a path outside `node_modules`; `not-global`
80
+ * an entry under a local `node_modules` tree, whose prefix the unit's npm
81
+ * cannot install into with `-g`.
82
+ */
83
+ export type NpmInstallResolution = {
84
+ ok: true;
85
+ target: NpmInstallTarget;
86
+ } | {
87
+ ok: false;
88
+ reason: 'no-entry';
89
+ } | {
90
+ ok: false;
91
+ reason: 'not-global';
92
+ entry: string;
93
+ };
69
94
  export declare function resolveNpmInstallTarget(spec: LaunchSpec, component: Component, opts: {
70
95
  windows: boolean;
71
- }): NpmInstallTarget | null;
96
+ exists?: (p: string) => boolean;
97
+ }): NpmInstallResolution;
72
98
  //# sourceMappingURL=manifest.d.ts.map
package/dist/cli.js CHANGED
@@ -15246,19 +15246,60 @@ function writeInstallManifest(instanceDir, fresh, launch, opts = {}) {
15246
15246
  function resolveNpmInstallTarget(spec, component, opts) {
15247
15247
  const entryRe = new RegExp(`[/\\\\]@kici-dev[/\\\\]${component}[/\\\\]dist[/\\\\](?:server|standalone)\\.js$`);
15248
15248
  const entry = [spec.execPath, ...spec.args].find((a) => entryRe.test(a));
15249
- if (!entry) return null;
15249
+ if (!entry) return {
15250
+ ok: false,
15251
+ reason: "no-entry"
15252
+ };
15250
15253
  const owningPackage = firstGlobalPackage(entry);
15251
- if (!owningPackage) return null;
15254
+ if (!owningPackage) return {
15255
+ ok: false,
15256
+ reason: "no-entry"
15257
+ };
15252
15258
  const p = opts.windows ? path.win32 : path.posix;
15253
- const npmBin = opts.windows ? "npm.cmd" : "npm";
15254
- const npmPath = p.join(p.dirname(spec.execPath), npmBin);
15259
+ const globalPrefix = globalPrefixOf(entry, opts.windows);
15260
+ if (!globalPrefix) return {
15261
+ ok: false,
15262
+ reason: "not-global",
15263
+ entry
15264
+ };
15265
+ const nodeDir = p.dirname(spec.execPath);
15266
+ const npmPath = p.join(nodeDir, opts.windows ? "npm.cmd" : "npm");
15267
+ const npmCliJs = opts.windows ? p.join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js") : p.join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js");
15268
+ const npmInvocation = (opts.exists ?? fs.existsSync)(npmCliJs) ? {
15269
+ cmd: spec.execPath,
15270
+ preArgs: [npmCliJs]
15271
+ } : {
15272
+ cmd: npmPath,
15273
+ preArgs: []
15274
+ };
15255
15275
  return {
15256
- nodeExecPath: spec.execPath,
15257
- npmPath,
15258
- owningPackage
15276
+ ok: true,
15277
+ target: {
15278
+ nodeExecPath: spec.execPath,
15279
+ npmPath,
15280
+ owningPackage,
15281
+ globalPrefix,
15282
+ npmInvocation
15283
+ }
15259
15284
  };
15260
15285
  }
15261
15286
  /**
15287
+ * The npm global prefix holding `entry`, split on its FIRST `node_modules`
15288
+ * segment like {@link firstGlobalPackage}. Windows lays a global install out as
15289
+ * `<prefix>\node_modules\<pkg>`; posix as `<prefix>/lib/node_modules/<pkg>`, so
15290
+ * there the segment before must be `lib`, which tells a global install from a
15291
+ * local `node_modules` tree. Null for anything else.
15292
+ */
15293
+ function globalPrefixOf(entry, windows) {
15294
+ const p = windows ? path.win32 : path.posix;
15295
+ const segments = entry.split(/[/\\]/);
15296
+ const idx = segments.indexOf("node_modules");
15297
+ if (idx < 1) return null;
15298
+ if (windows) return segments.slice(0, idx).join(p.sep) || null;
15299
+ if (idx < 2 || segments[idx - 1] !== "lib") return null;
15300
+ return segments.slice(0, idx - 1).join(p.sep) || p.sep;
15301
+ }
15302
+ /**
15262
15303
  * The package under the FIRST `node_modules` segment (the global root), which
15263
15304
  * `npm install -g` must target; `@scope/name` keeps both segments. Null
15264
15305
  * without a `node_modules` segment.
@@ -24448,14 +24489,18 @@ function planNpmSourceUpgrade(opts) {
24448
24489
  kind: "error",
24449
24490
  reason: "self-driving upgrade requires --version <target> (the version to install). Pass --restart-only to restart the already-installed package without installing."
24450
24491
  };
24451
- const target = spec ? resolveNpmInstallTarget(spec, component, { windows }) : null;
24452
- if (!target) return {
24492
+ const resolution = spec ? resolveNpmInstallTarget(spec, component, { windows }) : null;
24493
+ if (resolution && !resolution.ok && resolution.reason === "not-global") return {
24494
+ kind: "error",
24495
+ reason: `npm-source upgrade cannot self-install: the unit's launch target ${resolution.entry} is not inside a global npm install, so the unit's npm has no global prefix that holds it. The unit runs ${spec.execPath}; this kici-admin runs ${process.execPath}. Use an archive upgrade (--from <archive> / --url <url> + --version), or pre-install the package and re-run with --restart-only.`
24496
+ };
24497
+ if (!resolution?.ok) return {
24453
24498
  kind: "error",
24454
24499
  reason: "npm-source upgrade cannot self-install: the unit's launch target is a custom --binary install or a non-node_modules path, so there is no global package to update. Use an archive upgrade (--from <archive> / --url <url> + --version), or pre-install the package and re-run with --restart-only."
24455
24500
  };
24456
24501
  return {
24457
24502
  kind: "self-drive",
24458
- target,
24503
+ target: resolution.target,
24459
24504
  version: requestedVersion
24460
24505
  };
24461
24506
  }
@@ -24471,9 +24516,14 @@ function windowsShellCommand(cmd, args) {
24471
24516
  for (const arg of args) if (!/^[A-Za-z0-9@/._+-]+$/.test(arg)) throw new Error(`cannot pass "${arg}" to npm: it holds a character cmd.exe reads as syntax`);
24472
24517
  return [`"${cmd}"`, ...args].join(" ");
24473
24518
  }
24519
+ /**
24520
+ * Only a Windows batch file (`npm.cmd`) goes through cmd.exe; the pinned
24521
+ * `node.exe` running npm-cli.js spawns directly, with no shell to parse its
24522
+ * `C:\…` arguments.
24523
+ */
24474
24524
  const defaultSpawnRunner = (cmd, args, opts) => {
24475
24525
  const env = opts?.env ?? process.env;
24476
- const r = process.platform === "win32" ? spawnSync(windowsShellCommand(cmd, args), {
24526
+ const r = process.platform === "win32" && /\.cmd$/i.test(cmd) ? spawnSync(windowsShellCommand(cmd, args), {
24477
24527
  encoding: "utf-8",
24478
24528
  shell: true,
24479
24529
  env
@@ -24488,15 +24538,40 @@ const defaultSpawnRunner = (cmd, args, opts) => {
24488
24538
  };
24489
24539
  };
24490
24540
  /**
24491
- * The environment npm runs in: the unit's pinned node first on PATH, because
24492
- * npm derives its global prefix from the node that runs it.
24541
+ * The environment npm runs in: the unit's pinned node first on PATH, so a
24542
+ * wrapper fallback (`npmPath`, an `env node` shebang) resolves that node. The
24543
+ * caller's `npm_config_*` stay, so an operator-set registry or proxy still
24544
+ * applies.
24545
+ *
24546
+ * On the `npm.cmd` fallback the prefix goes through `npm_config_prefix`:
24547
+ * {@link windowsShellCommand} refuses the `\`, `:` and `=` a `--prefix=C:\…`
24548
+ * argument holds. Every other invocation passes `--prefix` on the command
24549
+ * line ({@link npmArgs}), which npm ranks above the environment.
24493
24550
  */
24494
24551
  function npmEnv(target) {
24495
24552
  const pinnedBinDir = path.dirname(target.nodeExecPath);
24496
- return {
24553
+ const env = {
24497
24554
  ...process.env,
24498
24555
  PATH: `${pinnedBinDir}${path.delimiter}${process.env.PATH ?? ""}`
24499
24556
  };
24557
+ if (prefixViaEnv(target)) {
24558
+ for (const key of Object.keys(env)) if (/^npm_config_(global_)?prefix$/i.test(key)) delete env[key];
24559
+ env.npm_config_prefix = target.globalPrefix;
24560
+ }
24561
+ return env;
24562
+ }
24563
+ /** Whether the prefix travels in the environment: the `npm.cmd` fallback. */
24564
+ function prefixViaEnv(target) {
24565
+ return target.npmInvocation.preArgs.length === 0 && /\.cmd$/i.test(target.npmInvocation.cmd);
24566
+ }
24567
+ /** The argv for one npm call: npm-cli.js first when the pinned node runs it, `--prefix` last. */
24568
+ function npmArgs(target, args) {
24569
+ const prefix = prefixViaEnv(target) ? [] : [`--prefix=${target.globalPrefix}`];
24570
+ return [
24571
+ ...target.npmInvocation.preArgs,
24572
+ ...args,
24573
+ ...prefix
24574
+ ];
24500
24575
  }
24501
24576
  /**
24502
24577
  * Whether `version` reads KICI_ENV_FILE, asked of the unit's npm registry. Such
@@ -24504,12 +24579,12 @@ function npmEnv(target) {
24504
24579
  * KiCI package of that version depends on.
24505
24580
  */
24506
24581
  function npmReleaseReadsEnvFile(target, version, run = defaultSpawnRunner) {
24507
- const res = run(target.npmPath, [
24582
+ const res = run(target.npmInvocation.cmd, npmArgs(target, [
24508
24583
  "view",
24509
24584
  `@kici-dev/shared@${version}`,
24510
24585
  "exports",
24511
24586
  "--json"
24512
- ], { env: npmEnv(target) });
24587
+ ]), { env: npmEnv(target) });
24513
24588
  if (res.status !== 0) return {
24514
24589
  ok: false,
24515
24590
  error: res.stderr.trim() || `npm view exited with ${res.status}`
@@ -24538,20 +24613,20 @@ function npmReleaseReadsEnvFile(target, version, run = defaultSpawnRunner) {
24538
24613
  };
24539
24614
  }
24540
24615
  /**
24541
- * Install `<owningPackage>@<version>` globally under the unit's pinned node, so
24542
- * it lands in the prefix the unit's ExecStart resolves from. npm derives its
24543
- * global prefix from the node that runs npm-cli.js, and the unit's
24544
- * `<pinned>/bin/npm` (an `env node` shebang or a version-manager wrapper)
24545
- * resolves `node` off PATH; run bare it would inherit the shell's active node,
24546
- * the mise/nvm/asdf drift this upgrade fixes. Hence the pinned bin dir goes
24547
- * first on PATH ({@link npmEnv}).
24616
+ * Install `<owningPackage>@<version>` globally into the prefix the unit's
24617
+ * ExecStart resolves from, with the unit's pinned node. Without an explicit
24618
+ * prefix npm derives one from the node that runs npm-cli.js, or takes the
24619
+ * `npm_config_prefix` the caller's environment carries (`npx` exports its
24620
+ * own), so the install would land where the unit never reads it. The pinned
24621
+ * node runs npm-cli.js directly, skipping a version-manager wrapper at
24622
+ * `<pinned>/bin/npm`, which also keeps the shell's active node out of it.
24548
24623
  */
24549
24624
  function installGlobalPackage(target, version, run = defaultSpawnRunner) {
24550
- const res = run(target.npmPath, [
24625
+ const res = run(target.npmInvocation.cmd, npmArgs(target, [
24551
24626
  "install",
24552
24627
  "-g",
24553
24628
  `${target.owningPackage}@${version}`
24554
- ], { env: npmEnv(target) });
24629
+ ]), { env: npmEnv(target) });
24555
24630
  return {
24556
24631
  ok: res.status === 0,
24557
24632
  stderr: res.stderr
@@ -24886,15 +24961,14 @@ async function performSelfDrivingInstall(args) {
24886
24961
  if (!verdict.reads) throw new Error(`refusing to install ${release} for "${config.name}": ${envFileRefusal(release, config.envFilePath)} The service and the installed package were not changed.`);
24887
24962
  }
24888
24963
  if (!opts.yes) {
24889
- console.log(`This will install ${target.owningPackage}@${version} using ${target.npmPath}`);
24890
- console.log(` (node runtime: ${target.nodeExecPath}), then restart "${config.name}".`);
24964
+ console.log(`This will install ${target.owningPackage}@${version} with ${target.nodeExecPath} into ${target.globalPrefix} (the unit's runtime), then restart "${config.name}".`);
24891
24965
  console.log("");
24892
24966
  if (!await confirm$1("Proceed with upgrade?")) {
24893
24967
  console.log("Upgrade cancelled.");
24894
24968
  return;
24895
24969
  }
24896
24970
  }
24897
- console.log(`Installing ${target.owningPackage}@${version} under the unit's runtime...`);
24971
+ console.log(`Installing ${target.owningPackage}@${version} with ${target.nodeExecPath} into ${target.globalPrefix} (the unit's runtime)...`);
24898
24972
  const result = installGlobalPackage(target, version, args.run);
24899
24973
  if (!result.ok) {
24900
24974
  const hint = /EACCES|permission denied|EPERM/i.test(result.stderr) ? " The unit's global prefix is not writable — re-run with privileges matching the unit's runtime." : "";
@@ -26603,7 +26677,7 @@ init_manifest();
26603
26677
  const ALL_PLATFORMS = AgentPlatform.options;
26604
26678
  /** The orchestrator's own version (single-version invariant) = the packaged agent version. */
26605
26679
  function resolveKiciVersion() {
26606
- return "0.18.0";
26680
+ return "0.19.0";
26607
26681
  }
26608
26682
  /** Parse the --platform value: default set | single | CSV | `all`. */
26609
26683
  function parsePlatforms(raw) {
@@ -35310,6 +35384,7 @@ function buildAgentContainerHostConfig(input) {
35310
35384
  ...typeof cpus === "number" && cpus > 0 ? { NanoCpus: Math.round(cpus * 1e9) } : {},
35311
35385
  ...input.binds && input.binds.length > 0 ? { Binds: input.binds } : {},
35312
35386
  ...input.extraHosts && input.extraHosts.length > 0 ? { ExtraHosts: input.extraHosts } : {},
35387
+ ...input.supplementaryGroups && input.supplementaryGroups.length > 0 ? { GroupAdd: input.supplementaryGroups.map(String) } : {},
35313
35388
  AutoRemove: false,
35314
35389
  PidsLimit: AGENT_CONTAINER_PIDS_LIMIT
35315
35390
  };
@@ -35374,6 +35449,19 @@ async function detectRuntime(runtimeHint) {
35374
35449
  * Create a LogCapture from a running container's stdout/stderr streams.
35375
35450
  * Uses dockerode's demuxStream to strip Docker's 8-byte multiplexed headers.
35376
35451
  */
35452
+ /**
35453
+ * The cpu / memory ceiling for one spawn. `effectiveLimits` from ScalerManager
35454
+ * wins: it already folds in job overrides, label-set and scaler defaults.
35455
+ * Otherwise the label set's (or the scaler's default) `resources.limits` apply.
35456
+ */
35457
+ function resolveSpawnLimits(effectiveLimits, resources) {
35458
+ if (effectiveLimits && (effectiveLimits.cpus || effectiveLimits.memBytes)) return effectiveLimits;
35459
+ const limits = resources?.limits;
35460
+ return {
35461
+ ...limits?.memory ? { memBytes: parseMemoryString(limits.memory) } : {},
35462
+ ...limits?.cpus ? { cpus: limits.cpus } : {}
35463
+ };
35464
+ }
35377
35465
  async function createContainerLogCapture(docker, containerId) {
35378
35466
  const stream = await docker.getContainer(containerId).logs({
35379
35467
  follow: true,
@@ -35661,15 +35749,7 @@ var ContainerScalerBackend = class ContainerScalerBackend {
35661
35749
  ];
35662
35750
  const binds = [...matchedLabelSet.volumes ?? []];
35663
35751
  if (matchedLabelSet.containerSocket && this.resolvedSocketPath) binds.push(`${this.resolvedSocketPath}:${this.resolvedSocketPath}`);
35664
- let resolvedLimits;
35665
- if (effectiveLimits && (effectiveLimits.cpus || effectiveLimits.memBytes)) resolvedLimits = effectiveLimits;
35666
- else {
35667
- const limits = (matchedLabelSet.resources ?? this.defaultResources)?.limits;
35668
- resolvedLimits = {
35669
- ...limits?.memory ? { memBytes: parseMemoryString(limits.memory) } : {},
35670
- ...limits?.cpus ? { cpus: limits.cpus } : {}
35671
- };
35672
- }
35752
+ const resolvedLimits = resolveSpawnLimits(effectiveLimits, matchedLabelSet.resources ?? this.defaultResources);
35673
35753
  const jobContainer = spawnContext?.container;
35674
35754
  const spawnImage = jobContainer?.image ?? matchedLabelSet.image;
35675
35755
  await pullImageIfMissing({
@@ -35692,6 +35772,7 @@ var ContainerScalerBackend = class ContainerScalerBackend {
35692
35772
  binds.push(runtimeInjectBind(runtimeVolume));
35693
35773
  agentInit = runtimeInitCommand(await agentImageLabels(this.docker, matchedLabelSet.image));
35694
35774
  }
35775
+ if (!jobContainer) env.push("KICI_SCALER_SINGLE_JOB=1");
35695
35776
  emit(ScalerEventType.enum["scaler.provisioning"], "creating container");
35696
35777
  const normalizedLabelSetStr = normalizeLabelSet(labelSet);
35697
35778
  const container = await this.docker.createContainer({
@@ -35710,7 +35791,8 @@ var ContainerScalerBackend = class ContainerScalerBackend {
35710
35791
  HostConfig: buildAgentContainerHostConfig({
35711
35792
  limits: resolvedLimits,
35712
35793
  binds,
35713
- ...this.extraHosts ? { extraHosts: this.extraHosts } : {}
35794
+ ...this.extraHosts ? { extraHosts: this.extraHosts } : {},
35795
+ ...!jobContainer && { supplementaryGroups: [1990] }
35714
35796
  }),
35715
35797
  ...this.networkIsolation && { NetworkingConfig: { EndpointsConfig: { ["kici-agent-net"]: {} } } }
35716
35798
  });
@@ -36844,7 +36926,7 @@ function routeRootArgs(root, args) {
36844
36926
  * build entry; the fallback covers a source run (tests, `tsx`), the same shape
36845
36927
  * `server.ts` and `standalone.ts` use for `ORCHESTRATOR_VERSION`.
36846
36928
  */
36847
- const CLI_VERSION = "0.18.0";
36929
+ const CLI_VERSION = "0.19.0";
36848
36930
  /**
36849
36931
  * Root command whose option parsing first routes argv through
36850
36932
  * {@link routeRootArgs}: a leading `--version` prints the CLI version, and an
@@ -156,6 +156,8 @@ export declare class DashboardHandler {
156
156
  private enforcePolicy;
157
157
  /** dashboard.run.detail: the run's nested job/step tree. */
158
158
  handleRunDetail(msg: DashboardRunDetailRequest): Promise<void>;
159
+ /** Answer a run-detail read for a run this orchestrator does not hold. */
160
+ private sendRunDetailNotFound;
159
161
  /**
160
162
  * dashboard.run.structured: the user-plane twin of the orchestrator-admin
161
163
  * `/runs/:id/structured` endpoint, byte-identical because it reuses the
@@ -71,7 +71,7 @@ export interface WorkflowDispatchContext {
71
71
  fullLockFile: {
72
72
  workflows: readonly LockWorkflow[];
73
73
  lockfileHash?: string;
74
- /** In-repo `workspace:` sibling closure digest; part of the dep pointer key. */
74
+ /** In-repo workspace sibling closure digest; part of the dep pointer key. */
75
75
  siblingsDigest?: string;
76
76
  source: {
77
77
  file: string;
@@ -193,14 +193,6 @@ export declare function narrowVerdict(raw: unknown, workflowName: string): Globa
193
193
  * best-effort post, so the check would vanish.
194
194
  */
195
195
  export declare const MAX_ROUND_REASON_CHARS = 4000;
196
- /**
197
- * Truncate `text` to at most `max` UTF-16 code units, marking that it was cut.
198
- *
199
- * - A `max` no larger than the marker returns the truncated marker, never a
200
- * string longer than `max`.
201
- * - The cut backs off one unit rather than split a surrogate pair.
202
- */
203
- export declare function truncateReasonText(text: string, max: number): string;
204
196
  /**
205
197
  * One round that produced no verdicts after every attempt. Exactly one per
206
198
  * failed round, never one per candidate (the round exists to collapse N
@@ -2,7 +2,8 @@
2
2
  * Bare-metal scaler backend: ephemeral agent processes via child_process.spawn.
3
3
  * On Linux and macOS each agent leads its own process group, which teardown
4
4
  * signals; on Windows teardown ends the agent's process tree with taskkill.
5
- * All agents are single-use: destroyed after job completion.
5
+ * An agent exits once it is idle past its idle window (a follow-up job that
6
+ * arrives within the window reuses it), and the backend destroys it on disconnect.
6
7
  */
7
8
  import { type ToolRequirement } from '@kici-dev/shared';
8
9
  import type { AgentTokenStore } from '../agent/token-store.js';
@@ -15,6 +15,13 @@ import type { EffectiveLimits } from './types.js';
15
15
  * its own docs and validation, and nothing yet says the constant is wrong.
16
16
  */
17
17
  export declare const AGENT_CONTAINER_PIDS_LIMIT = 4096;
18
+ /**
19
+ * Group id of `kici-sudo` in the KiCI agent image (`packages/agent/Dockerfile`
20
+ * creates it). `/etc/sudoers.d/kici-sudo` grants that group passwordless sudo.
21
+ * The container scaler adds the group only to the single-job containers it
22
+ * starts from a pool's own image, so root never outlives one job.
23
+ */
24
+ export declare const AGENT_SUDO_GROUP_GID = 1990;
18
25
  /**
19
26
  * Why this container is NOT capability-dropped, though the job containers the
20
27
  * agent starts are.
@@ -42,6 +49,8 @@ export interface AgentContainerHostConfigInput {
42
49
  binds?: string[];
43
50
  /** Extra `host:ip` entries (e.g. a private registry alias). */
44
51
  extraHosts?: string[];
52
+ /** Extra group ids for the container's process (`GroupAdd`). */
53
+ supplementaryGroups?: number[];
45
54
  }
46
55
  /**
47
56
  * Build the agent container's `HostConfig`.
@@ -19,7 +19,10 @@ export interface WarmPoolCallbacks {
19
19
  countAvailable: (labels: string[]) => number;
20
20
  /**
21
21
  * Agents this backend spawned that match the label set and have never run a
22
- * job. `registeredAt` doubles as "idle since": scaler agents are single-use.
22
+ * job. `registeredAt` stands in for "idle since". That is exact for a
23
+ * container-backend agent, which exits after its one job. An agent of another
24
+ * backend can finish a job and wait out its idle window before it exits, so it
25
+ * can read as idle for longer than it has been.
23
26
  *
24
27
  * Scoped to the backend because the reaper destroys what it is given; a
25
28
  * matching static agent is ready capacity, not this pool's to tear down.