@kici-dev/orchestrator 0.1.13 → 0.1.14

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.
@@ -0,0 +1,16 @@
1
+ import { type Kysely } from 'kysely';
2
+ /**
3
+ * Add `init_failure jsonb` columns to `execution_runs` and `execution_jobs`.
4
+ *
5
+ * Presence of this column on a row means the run/job never executed a step
6
+ * because of an init-phase failure; absence (NULL) means a normal run.
7
+ * Shape on the wire is `InitFailure` from `@kici-dev/engine`. The dashboard
8
+ * reads this column directly so it can render the right banner without
9
+ * round-tripping to the orchestrator (which may be offline).
10
+ *
11
+ * Idempotent: re-running on a DB that already has either column is a no-op
12
+ * for that column.
13
+ */
14
+ export declare function up(db: Kysely<unknown>): Promise<void>;
15
+ export declare function down(db: Kysely<unknown>): Promise<void>;
16
+ //# sourceMappingURL=025_init_failure.d.ts.map
@@ -1,4 +1,5 @@
1
1
  import type { ColumnType, Generated, Insertable, Selectable, Updateable } from 'kysely';
2
+ import type { InitFailure } from '@kici-dev/engine';
2
3
  /**
3
4
  * PostgreSQL-only database types.
4
5
  * Column names use snake_case matching the actual database column names.
@@ -258,6 +259,13 @@ export interface ExecutionRunTable {
258
259
  contributor_username: string | null;
259
260
  /** Human-readable reason why the run failed (null for non-failed runs). */
260
261
  failure_reason: string | null;
262
+ /**
263
+ * Structured init-phase failure detail (shape: `InitFailure` from
264
+ * `@kici-dev/engine`). Non-null means the run never executed a step
265
+ * because something failed during the init phase (lock-file fetch,
266
+ * provider context, agent spawn). NULL for normal runs.
267
+ */
268
+ init_failure: ColumnType<InitFailure | null, unknown, unknown>;
261
269
  /** When this record was created */
262
270
  created_at: Generated<Date>;
263
271
  /**
@@ -304,6 +312,13 @@ export interface ExecutionJobTable {
304
312
  log_bytes: Generated<number>;
305
313
  /** Error info if failed */
306
314
  error_message: string | null;
315
+ /**
316
+ * Structured init-phase failure detail (shape: `InitFailure` from
317
+ * `@kici-dev/engine`). Non-null means the job never executed a step
318
+ * because something failed during init (lock-file fetch, provider
319
+ * context, agent spawn). NULL for normal runs.
320
+ */
321
+ init_failure: ColumnType<InitFailure | null, unknown, unknown>;
307
322
  /** Labels used for agent routing (e.g. ["kici:os:linux", "kici:arch:x64"]). JSONB. */
308
323
  runs_on_labels: string | null;
309
324
  /** Last heartbeat received from agent (for stale run detection) */
@@ -11,7 +11,8 @@ import { checkAgentConnectivity } from './agents.js';
11
11
  import { checkDiskSpace } from './disk.js';
12
12
  import { checkConfigValidity } from './config.js';
13
13
  import { checkCertificateExpiry } from './certs.js';
14
+ import { checkScalerProvisioning } from './scaler.js';
14
15
  /** All diagnostic checks in display order. */
15
16
  export declare const defaultChecks: DiagnosticCheck[];
16
- export { checkDbConnectivity, checkWsToPlatform, checkAgentConnectivity, checkDiskSpace, checkConfigValidity, checkCertificateExpiry, };
17
+ export { checkDbConnectivity, checkWsToPlatform, checkAgentConnectivity, checkDiskSpace, checkConfigValidity, checkCertificateExpiry, checkScalerProvisioning, };
17
18
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Scaler provisioning diagnostic check.
3
+ *
4
+ * Reads recent scaler spawn failures (in-process, over a short rolling window)
5
+ * and emits one row per configured scaler backend instance. Severity is
6
+ * bound-aware: a warm-pool/unbound failure is a warning (no run impact yet); a
7
+ * job-bound failure is a failure (a queued run could not get an agent).
8
+ */
9
+ import type { DiagnosticDeps, DiagnosticResult } from '../types.js';
10
+ /** Rolling window for "recent" spawn failures. */
11
+ export declare const SCALER_FAILURE_WINDOW_MS: number;
12
+ export declare function checkScalerProvisioning(deps: DiagnosticDeps): Promise<DiagnosticResult[]>;
13
+ //# sourceMappingURL=scaler.d.ts.map
@@ -7,6 +7,7 @@
7
7
  import type { Kysely } from 'kysely';
8
8
  import type { Database } from '../db/types.js';
9
9
  import type { AgentRegistry } from '../agent/registry.js';
10
+ import type { ScalerManager } from '../scaler/manager.js';
10
11
  /** Result of a single diagnostic check. */
11
12
  export interface DiagnosticResult {
12
13
  /** Human-readable check name (e.g., "Database connectivity"). */
@@ -32,7 +33,9 @@ export interface DiagnosticDeps {
32
33
  config: Record<string, unknown>;
33
34
  /** TLS cert path (for expiry check). */
34
35
  tlsCertPath?: string;
36
+ /** Scaler manager for recent spawn-failure health (optional -- no scaler configured). */
37
+ scalerManager?: ScalerManager;
35
38
  }
36
- /** A diagnostic check function. */
37
- export type DiagnosticCheck = (deps: DiagnosticDeps) => Promise<DiagnosticResult>;
39
+ /** A diagnostic check function. May return one result or several (e.g. one per scaler backend). */
40
+ export type DiagnosticCheck = (deps: DiagnosticDeps) => Promise<DiagnosticResult | DiagnosticResult[]>;
38
41
  //# sourceMappingURL=types.d.ts.map
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { type Kysely } from 'kysely';
15
15
  import type { Database } from '../db/types.js';
16
- import { ExecutionRunStatus, ScalerEventType } from '@kici-dev/engine';
16
+ import { ExecutionRunStatus, type InitFailure, ScalerEventType } from '@kici-dev/engine';
17
17
  import type { ObserverRegistry } from '../ws/observer-registry.js';
18
18
  import type { LogStorage } from './log-storage.js';
19
19
  import type { JobQueue } from '../queue/job-queue.js';
@@ -92,7 +92,13 @@ export interface ExecutionTrackerDeps {
92
92
  * Set on terminal run states only. Powers the operator-side
93
93
  * `kici_org_log_bytes` capacity-planning gauge on the Platform.
94
94
  */
95
- logBytes?: number) => void;
95
+ logBytes?: number,
96
+ /**
97
+ * Structured init-failure signal. Set when the run never executed a step
98
+ * because of an init-phase failure. Forwarded to Platform's execution.status
99
+ * forward and persisted in execution_runs.init_failure on both sides.
100
+ */
101
+ initFailure?: InitFailure) => void;
96
102
  /** Optional callback to forward job status changes to Platform.
97
103
  * Fires on every job state transition (pending->running, running->success, etc.).
98
104
  * Used to populate Platform's execution_jobs projection table. */
@@ -101,7 +107,12 @@ export interface ExecutionTrackerDeps {
101
107
  * Total raw log bytes accumulated across the job (sum of per-step totals).
102
108
  * Set on terminal job states only.
103
109
  */
104
- logBytes?: number) => void;
110
+ logBytes?: number,
111
+ /**
112
+ * Structured init-failure signal. Set for synthetic rejected-* / init-failed-*
113
+ * jobs that never started. Persisted in execution_jobs.init_failure.
114
+ */
115
+ initFailure?: InitFailure) => void;
105
116
  /**
106
117
  * Optional callback to emit run.event messages to Platform.
107
118
  * Fires at orchestrator lifecycle points (dispatch, agent assignment, job start/complete).
@@ -142,6 +153,22 @@ export declare class ExecutionTracker {
142
153
  private readonly orgId?;
143
154
  private readonly jobQueue?;
144
155
  private readonly runs;
156
+ /**
157
+ * Per-run async-mutex chain. `onJobStatus` and `addJobsToRun` mutate the same
158
+ * `run.jobs` Map / `execution_jobs` row; without serialization a job-status
159
+ * reply that lands mid synthetic→real swap (`addJobsToRun`) clobbers the swap
160
+ * and the run hangs in `running` forever (see `withRunLock`). The map value is
161
+ * the tail of the chain for that runId; entries are GC'd when the last holder
162
+ * releases.
163
+ */
164
+ private readonly runLockTails;
165
+ /**
166
+ * Tracks which runIds the current async context already holds in
167
+ * `withRunLock`, so reentrant calls (e.g. onJobStatus → scheduler hook →
168
+ * dispatchReadyJob → addJobsToRun, all the same runId) bypass re-acquisition
169
+ * instead of deadlocking. Propagates across awaits via AsyncLocalStorage.
170
+ */
171
+ private readonly heldRunLocks;
145
172
  /** Tracks which runs are test runs for observer broadcasting. */
146
173
  private readonly testRunIds;
147
174
  /**
@@ -208,6 +235,22 @@ export declare class ExecutionTracker {
208
235
  * which peer owns the downstream dispatch.
209
236
  */
210
237
  findSyntheticJobId(runId: string, jobName: string): Promise<string | undefined>;
238
+ /**
239
+ * Run `fn` while holding a per-run lock, serializing the run-mutating methods
240
+ * (`onJobStatus`, `addJobsToRun`) so a status reply cannot interleave with the
241
+ * synthetic→real job swap and wedge the run in `running`.
242
+ *
243
+ * The lock is **reentrant**: three paths re-enter a locked method within the
244
+ * same async context for the same runId — onJobStatus → scheduler hook →
245
+ * dispatchReadyJob → addJobsToRun; onJobStatus → enforceSchedulerInvariant →
246
+ * onJobStatus; runSchedulerHook → onJobStatus (skip). A non-reentrant mutex
247
+ * would deadlock on these, so a context that already holds the runId's lock
248
+ * (tracked via `heldRunLocks`) runs `fn` inline. A genuinely concurrent caller
249
+ * for the same runId (a separate WS message) is a different async context and
250
+ * correctly waits. All reentrant paths are same-runId, so there is no
251
+ * cross-run lock-ordering deadlock.
252
+ */
253
+ private withRunLock;
211
254
  addJobsToRun(runId: string, jobs: Array<{
212
255
  jobId: string;
213
256
  jobName: string;
@@ -216,6 +259,7 @@ export declare class ExecutionTracker {
216
259
  }>, dispatchedContexts?: string[],
217
260
  /** Synthetic job ID to replace (e.g. needs-pending-deploy-{uuid}). */
218
261
  replaceSyntheticId?: string): Promise<void>;
262
+ private addJobsToRunImpl;
219
263
  /**
220
264
  * Mark a run as a test run for observer broadcasting.
221
265
  * Called by the test pipeline after creating the execution run.
@@ -233,6 +277,7 @@ export declare class ExecutionTracker {
233
277
  * fires the onExecutionComplete callback.
234
278
  */
235
279
  onJobStatus(runId: string, jobId: string, state: string, timestamp: number, agentId?: string, data?: Record<string, unknown>): Promise<void>;
280
+ private onJobStatusImpl;
236
281
  /**
237
282
  * Phase 1a: recover run state from the DB when in-memory tracking is empty.
238
283
  * Returns the rehydrated RunState or null if the run is unknown to the DB
@@ -313,7 +358,7 @@ export declare class ExecutionTracker {
313
358
  * onExecutionStarted but the build subsequently fails. Without this,
314
359
  * the execution_runs row would stay in a non-terminal state.
315
360
  */
316
- onBuildFailed(runId: string): Promise<void>;
361
+ onBuildFailed(runId: string, initFailure?: InitFailure): Promise<void>;
317
362
  /**
318
363
  * Create a failed execution run when the build timed out before onExecutionStarted
319
364
  * had a chance to insert the row (buildJobTrackedEarly was false).
@@ -321,7 +366,33 @@ export declare class ExecutionTracker {
321
366
  * Inserts a minimal execution_runs row with status='failed' directly so the E2E
322
367
  * test (and dashboard) can observe the failure instead of a missing run.
323
368
  */
324
- onBuildFailedBeforeTracking(runId: string, workflowName: string, provider: string, repoIdentifier: string, ref: string, sha: string, deliveryId: string | null, providerContext: Record<string, unknown>, routingKey: string, triggerEvent?: string, commitMessage?: string, failureReason?: string): Promise<void>;
369
+ onBuildFailedBeforeTracking(runId: string, workflowName: string, provider: string, repoIdentifier: string, ref: string, sha: string, deliveryId: string | null, providerContext: Record<string, unknown>, routingKey: string, triggerEvent?: string, commitMessage?: string, failureReason?: string, initFailure?: InitFailure): Promise<void>;
370
+ /**
371
+ * Insert a `failed` execution_runs row directly for an init failure that
372
+ * occurred BEFORE onExecutionStarted ran (so no in-memory state exists
373
+ * and no jobs were dispatched). Also writes the structured init_failure
374
+ * signal and fires onExecutionStatusChange so Platform's projection picks
375
+ * it up via the normal forward path. Idempotent: if a row already exists
376
+ * for this runId, the insert is a no-op (ON CONFLICT DO NOTHING).
377
+ *
378
+ * Closes the silent pre-run-failure gap — without this helper, secret /
379
+ * install-secret / all-jobs-rejected early-exits in dispatch-matched-workflow
380
+ * leave no trace on the dashboard.
381
+ */
382
+ recordInitFailureRun(args: {
383
+ runId: string;
384
+ workflowName: string;
385
+ provider: string;
386
+ repoIdentifier: string;
387
+ ref: string;
388
+ sha: string;
389
+ deliveryId: string | null;
390
+ providerContext: Record<string, unknown>;
391
+ routingKey: string;
392
+ initFailure: InitFailure;
393
+ triggerEvent?: string;
394
+ commitMessage?: string;
395
+ }): Promise<void>;
325
396
  /**
326
397
  * Mark a run as failed immediately with a reason message.
327
398
  *
@@ -330,7 +401,7 @@ export declare class ExecutionTracker {
330
401
  * Instead of leaving the run in 'running' for OrphanRecovery to catch after 5 min,
331
402
  * this fails it right away.
332
403
  */
333
- failRun(runId: string, reason: string): Promise<void>;
404
+ failRun(runId: string, reason: string, initFailure?: InitFailure): Promise<void>;
334
405
  /**
335
406
  * Update step status within a job.
336
407
  *
@@ -0,0 +1,46 @@
1
+ /**
2
+ * In-process bounded record of recent scaler spawn failures.
3
+ *
4
+ * The scaler manager records every `scaler.failed` event here at the same site
5
+ * it increments the fleet-wide Prometheus counter. The diagnose scaler check
6
+ * reads recent failures grouped per backend instance to produce its rows. This
7
+ * is memory-only and bounded — the recent-failures window resets on restart,
8
+ * which is acceptable for an on-demand operator view over a short window.
9
+ */
10
+ /** A single recorded scaler spawn failure. */
11
+ export interface ScalerFailureRecord {
12
+ /** Scaler instance name (the configured scaler `name`); the diagnose row key. */
13
+ backendName: string;
14
+ /** Backend type: 'container' | 'bare-metal' | 'firecracker' | 'unknown'. */
15
+ backendType: string;
16
+ /** True when the failed spawn was bound to a queued job (a run was affected). */
17
+ bound: boolean;
18
+ /** Captured error string from the scaler event detail. */
19
+ detail: string;
20
+ /** Event timestamp in epoch milliseconds. */
21
+ timestampMs: number;
22
+ }
23
+ /** Per-backend summary of recent failures within a window. */
24
+ export interface BackendFailureSummary {
25
+ backendType: string;
26
+ boundCount: number;
27
+ unboundCount: number;
28
+ /** Detail of the most recent failure in the window. */
29
+ lastError: string;
30
+ /** Timestamp of the most recent failure in the window. */
31
+ lastAtMs: number;
32
+ }
33
+ export declare class ScalerFailureTracker {
34
+ private readonly records;
35
+ private readonly maxEntries;
36
+ constructor(maxEntries?: number);
37
+ /** Record a failure, evicting the oldest entry when over capacity. */
38
+ record(rec: ScalerFailureRecord): void;
39
+ /**
40
+ * Group failures newer than `nowMs - windowMs` by backend instance name.
41
+ * `nowMs` is injected so callers control the clock (and tests are
42
+ * deterministic).
43
+ */
44
+ recentByBackend(windowMs: number, nowMs: number): Map<string, BackendFailureSummary>;
45
+ }
46
+ //# sourceMappingURL=failure-tracker.d.ts.map
@@ -7,6 +7,7 @@
7
7
  * and manages the agent lifecycle from spawn to destroy.
8
8
  */
9
9
  import type { ResourceRequest } from '@kici-dev/engine';
10
+ import type { BackendFailureSummary } from './failure-tracker.js';
10
11
  import type { ScalerBackend, ScalerConfig, ScaleResult, ScalerEvent, ResourceCap, ValidationResult } from './types.js';
11
12
  import type { ScalerStateStore, ScalerStateRecovery } from './scaler-state-store.js';
12
13
  /**
@@ -80,6 +81,8 @@ export interface ScalerStatus {
80
81
  export declare class ScalerManager {
81
82
  private readonly backends;
82
83
  private readonly backendRoles;
84
+ /** Recent scaler spawn failures, surfaced by `kici-admin diagnose`. */
85
+ private readonly failureTracker;
83
86
  private globalMaxAgents;
84
87
  /** Per-scaler resource caps (`{ maxCpu, maxMemoryBytes }`), keyed by scaler name. */
85
88
  private readonly resourceCaps;
@@ -320,6 +323,11 @@ export declare class ScalerManager {
320
323
  /**
321
324
  * Return status summary for metrics and health endpoints.
322
325
  */
326
+ /**
327
+ * Recent scaler spawn failures grouped per backend instance, for the
328
+ * diagnose scaler check. `nowMs` is injected by the caller.
329
+ */
330
+ recentSpawnFailures(windowMs: number, nowMs: number): Map<string, BackendFailureSummary>;
323
331
  getStatus(): ScalerStatus;
324
332
  /**
325
333
  * Get the backend name managing a specific agent.