Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { activeEmbedModelId, resolveEmbeddingsConfig, validateEmbedDimension } f
import { resolveEmbedProvider, type EmbedProvider } from "./embeddings/provider.js";
import { stderrDimRejectionSink } from "./embeddings/guard.js";
import { DeepLakeEnricherStore } from "./enricher/store-adapter.js";
import { createRefreshSignal } from "./enricher/refresh-signal.js";
import { createPriorContentCache } from "./enricher/content-cache.js";
import type { ContentReader } from "./enricher/index.js";
import type { PipelineMetricsSink } from "./telemetry/index.js";
Expand Down Expand Up @@ -870,6 +871,12 @@ function buildLiveDurableWiring(

async function runDaemon(): Promise<void> {
const ctx = safeResolveContext();
const config = resolveConfig();
// Scale-to-zero: the registration leg marks this dirty on every durable write,
// and the enricher consumes it to decide whether a cycle is worth a Deep Lake
// refresh. An idle repo never marks dirty, so the enricher issues zero Deep Lake
// reads and the Activeloop pod scales to zero (parity with honeycomb PRD-062b).
const refreshSignal = createRefreshSignal();
const portkey = resolvePortkeyConfig();
const embeddingsConfig = resolveEmbeddingsConfig({});
// AC-018i.7: catch a wrong NECTAR_EMBEDDINGS_OUTPUT_DIMENSION at config
Expand Down Expand Up @@ -907,13 +914,23 @@ async function runDaemon(): Promise<void> {

const options: AssembleOptions = {
broodPrereqs,
// Kill switch: NECTAR_ENRICHER_ENABLED=false runs registration + on-demand
// brood but no steady-state enricher poll (zero idle Deep Lake reads).
enricherEnabled: config.enricherEnabled,
...(bootProjection !== undefined ? { bootProjection } : {}),
// PRD-018b: when Deep Lake creds resolve, start the update-on-change watch
// pipeline against the durable store (the watch leg needs no LLM - it appends
// pending version rows the enricher describes later). Absent creds -> the
// watch leg stays dormant and /health says so (AC-018b.7).
...(ctx !== undefined
? { tenancy: ctx.tenancy, projectRoot: ctx.projectRoot, registrationStore: ctx.store }
? {
tenancy: ctx.tenancy,
projectRoot: ctx.projectRoot,
registrationStore: ctx.store,
// Producer half of the scale-to-zero refresh gate: a durable registration
// write means Deep Lake has a row the enricher's mirror needs to pull.
onRegistrationDurableWrite: () => refreshSignal.markDirty(),
}
: {}),
...(live !== undefined && ctx !== undefined
? {
Expand All @@ -933,8 +950,12 @@ async function runDaemon(): Promise<void> {
: {}),
// AC-018i.1: stamp embed_model on enricher-embedded rows.
embedModel,
// AC-018g.6: re-seed the working set each cycle from the durable store.
// AC-018g.6: re-seed the working set from the durable store, but only
// when the registration leg has flagged new durable rows. Consumer half
// of the scale-to-zero refresh gate: an idle repo never marks dirty, so
// the enricher issues zero Deep Lake reads and the pod scales to zero.
refreshWorkingSet: () => live.enricher.refresh(ctx.tenancy),
shouldRefresh: () => refreshSignal.consume(),
// AC-018g.9: prior-content cache backs the cosmetic-change gate.
priorContentCache: createPriorContentCache(),
// AC-018g.11/.12: trigger #2 - a debounced projection write after a
Expand Down
35 changes: 34 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ export function isLoopbackHost(host: string): boolean {
/** Worker poll floor: the enricher's 30s cadence (ai/enricher-and-llm-model.md). */
export const DEFAULT_POLL_INTERVAL_MS = 30_000;

/**
* Whether the steady-state enricher loop runs. `NECTAR_ENRICHER_ENABLED=false`
* keeps registration (fs-watch) and on-demand `brood` fully working but disables
* the background enricher poll, so an idle daemon issues ZERO Deep Lake reads.
* This is the operator kill switch for the scale-to-zero regression (an idle
* enricher otherwise refreshes Deep Lake every cycle): run nectar with the
* enricher off until the dirty-gated refresh is on the version you have deployed.
* Default: enabled.
*/
export const DEFAULT_ENRICHER_ENABLED = true;

/** Distinct from honeycomb's `daemon.pid`/`daemon.lock` so both daemons coexist in ~/.honeycomb (PRD-002d). */
export const DEFAULT_PID_FILE_NAME = "nectar.pid";
export const DEFAULT_LOCK_FILE_NAME = "nectar.lock";
Expand All @@ -57,6 +68,7 @@ export interface RuntimeConfig {
readonly pidFilePath: string;
readonly lockFilePath: string;
readonly pollIntervalMs: number;
readonly enricherEnabled: boolean;
}

export interface RuntimeConfigOverrides {
Expand All @@ -66,6 +78,7 @@ export interface RuntimeConfigOverrides {
readonly pidFileName?: string;
readonly lockFileName?: string;
readonly pollIntervalMs?: number;
readonly enricherEnabled?: boolean;
}

/**
Expand Down Expand Up @@ -102,6 +115,21 @@ function envStr(name: string): string | undefined {
return raw;
}

/**
* Read a boolean env var. Accepts 1/true/yes/on and 0/false/no/off (case
* insensitive). Unset, blank, or unrecognized returns `undefined` so the caller
* falls back to a default (an unrecognized value is treated as absent rather than
* silently coerced, matching the strict posture of `envInt`).
*/
function envBool(name: string): boolean | undefined {
const raw = envStr(name);
if (raw === undefined) return undefined;
const v = raw.trim().toLowerCase();
if (v === "1" || v === "true" || v === "yes" || v === "on") return true;
if (v === "0" || v === "false" || v === "no" || v === "off") return false;
return undefined;
}

/**
* Resolve the runtime config. Overrides win, then env, then defaults. The env
* layer keeps the daemon operable without a config file; overrides exist so
Expand All @@ -120,8 +148,13 @@ export function resolveConfig(overrides: RuntimeConfigOverrides = {}): RuntimeCo
envInt("NECTAR_POLL_INTERVAL_MS", { min: MIN_POLL_INTERVAL_MS }) ??
DEFAULT_POLL_INTERVAL_MS;

const enricherEnabled =
overrides.enricherEnabled ??
envBool("NECTAR_ENRICHER_ENABLED") ??
DEFAULT_ENRICHER_ENABLED;

const pidFilePath = join(runtimeDir, overrides.pidFileName ?? DEFAULT_PID_FILE_NAME);
const lockFilePath = join(runtimeDir, overrides.lockFileName ?? DEFAULT_LOCK_FILE_NAME);

return { host, port, runtimeDir, pidFilePath, lockFilePath, pollIntervalMs };
return { host, port, runtimeDir, pidFilePath, lockFilePath, pollIntervalMs, enricherEnabled };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
19 changes: 17 additions & 2 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import type { AsyncHiveGraphStore, HiveGraphStore } from "./hive-graph/store.js"
import { createDiskRegistrationFs } from "./registration/disk-fs.js";
import { RegistrationService, type RegistrationFs } from "./registration/service.js";
import type { WatcherState } from "./registration/fs-watch.js";
import { StoreBridge } from "./registration/store-bridge.js";
import { StoreBridge, type DurableWriteOp } from "./registration/store-bridge.js";
import { createSharedIgnore, type IgnorePredicate } from "./registration/ignore.js";
import { createTlshFuzzyStep, DEFAULT_TUNABLE_FUZZY_CONFIG, type FuzzyConfig } from "./registration/tlsh.js";
import { FilePendingReviewStore, type PendingReviewStore } from "./registration/review-store.js";
Expand Down Expand Up @@ -239,6 +239,13 @@ export interface AssembleOptions extends RuntimeConfigOverrides {
readonly registrationTimer?: Timer;
/** Debounce window for the intake (default: the intake's own `DEFAULT_DEBOUNCE_MS`). */
readonly registrationDebounceMs?: number;
/**
* Notified after each SUCCESSFUL durable registration write (the point new rows
* land in Deep Lake). Production wires it to the enricher refresh-signal so the
* enricher only re-reads Deep Lake after real file activity - an idle repo never
* triggers a refresh and the serverless pod scales to zero. Default: no-op.
*/
readonly onRegistrationDurableWrite?: (op: DurableWriteOp) => void;

// ── Wave D daemon API surface (PRD-008 / PRD-012b) ──────────────────────────
/**
Expand Down Expand Up @@ -525,7 +532,12 @@ export function assembleDaemon(options: AssembleOptions = {}): AssembledDaemon {
// `enricherStore` (see enricher/store-adapter.ts); the default is an empty
// in-memory working set.
let enricherLoop: EnricherLoop | null = null;
if (options.enricherEnabled ?? true) {
// Gate on the RESOLVED config (resolveConfig(options), line ~454), not the raw
// option: config.enricherEnabled honors an explicit `enricherEnabled` override
// AND the NECTAR_ENRICHER_ENABLED env, and keeps daemon.config in sync with the
// actual runtime behavior. Gating on options.enricherEnabled alone would ignore
// the env for any caller that does not re-forward the resolved value.
if (config.enricherEnabled) {
const enricherStore: EnricherStore = options.enricherStore ?? new EnricherInMemoryStore();
const nowIso = options.enricherCycle?.nowIso ?? (() => new Date().toISOString());
const enricherHealthSink: EnricherCycleLogSink = {
Expand Down Expand Up @@ -679,6 +691,9 @@ export function assembleDaemon(options: AssembleOptions = {}): AssembledDaemon {
health.recordWatchFlushFailure(new Date().toISOString());
log({ level: "error", scope: "registration.bridge", op, err: String(err) });
},
...(options.onRegistrationDurableWrite !== undefined
? { onDurableWrite: options.onRegistrationDurableWrite }
: {}),
});
const reviews: PendingReviewStore =
options.registrationReviews ?? new FilePendingReviewStore(join(config.runtimeDir, "pending-reviews.json"));
Expand Down
18 changes: 16 additions & 2 deletions src/enricher/cycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ export interface EnricherCycleDeps {
* NEC-016 AC-018g.6) so post-boot pending rows are picked up without a restart.
*/
readonly refreshWorkingSet?: () => Promise<void>;
/**
* Gate for {@link refreshWorkingSet}: returns true when a durable refresh is
* worth a Deep Lake round trip. Production wires it to the registration
* dirty-signal's `consume()`, so an idle repo (no file changes) refreshes zero
* times and the Activeloop serverless pod idles to zero (scale-to-zero parity
* with honeycomb PRD-062b). Absent OR returning true => refresh every cycle
* (the pre-existing behavior every current caller and test relies on).
*/
readonly shouldRefresh?: () => boolean;
/** Prior-content cache for the cosmetic-change gate (PRD-018g / NEC-026 AC-018g.9). */
readonly priorContentCache?: PriorContentCache;
/** Active embedding model id stamped on rows carrying an embedding (PRD-018i / NEC-018 AC-018i.1). */
Expand Down Expand Up @@ -337,8 +346,13 @@ export async function runEnricherCycle(
const now = deps.nowIso?.() ?? new Date().toISOString();
const logSink = deps.logSink ?? consoleCycleLogSink;

// Working-set freshness (AC-018g.6): re-seed from the durable store first.
if (deps.refreshWorkingSet !== undefined) {
// Working-set freshness (AC-018g.6): re-seed from the durable store first, but
// ONLY when a refresh could surface new rows. `shouldRefresh` (wired to the
// registration dirty-signal) is false on an idle repo, so an idle enricher
// issues NO Deep Lake read and the serverless pod can scale to zero
// (scale-to-zero parity with honeycomb PRD-062b). Absent `shouldRefresh` =>
// refresh every cycle (the legacy behavior).
if (deps.refreshWorkingSet !== undefined && (deps.shouldRefresh?.() ?? true)) {
try {
await deps.refreshWorkingSet();
} catch {
Expand Down
54 changes: 54 additions & 0 deletions src/enricher/refresh-signal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* The enricher working-set refresh signal (scale-to-zero).
*
* The steady-state enricher cycle re-seeds its in-memory mirror from Deep Lake
* (`refreshWorkingSet` -> `DeepLakeEnricherStore.refresh` -> a
* `SELECT ... FROM hive_graph_versions`) so version rows the registration watcher
* appended after boot become visible to the enricher's SEPARATE mirror. Doing
* that unconditionally on every ~30s tick means an idle repo (no file changes,
* nothing to enrich) still issues a Deep Lake read every 30s forever, which keeps
* the Activeloop serverless pod warm and defeats its scale-to-zero. That is the
* same idle-burn class honeycomb removed in PRD-062b; nectar re-introduced it by
* refreshing on every cycle.
*
* This one-bit signal gates the refresh. The registration leg is the ONLY
* producer of new durable rows this daemon writes, so it `markDirty()`s after
* each successful durable write; the enricher `consume()`s the flag to decide
* whether a refresh is worth a network round trip. When no file has changed the
* flag stays clear, the enricher issues NO Deep Lake read, and the pod idles to
* zero. The enricher's own describe write-backs go through a different store and
* never mark this signal, so there is no self-triggering refresh loop.
*/

/** A read-and-clear "durable rows may have changed" flag shared by producer and consumer. */
export interface RefreshSignal {
/** Producer (registration): a durable write happened; the next enricher tick should refresh. */
markDirty(): void;
/** Consumer (enricher): read-and-clear. Returns true (and resets to clean) when a refresh is warranted. */
consume(): boolean;
/** Non-destructive read, for tests and observability. Does NOT clear the flag. */
readonly dirty: boolean;
}

/**
* Create a {@link RefreshSignal}. Defaults to dirty so the FIRST enricher cycle
* always refreshes once (picking up any rows persisted before this boot, e.g. a
* prior session that appended `pending` rows but never described them); every
* subsequent refresh is gated on real registration activity.
*/
export function createRefreshSignal(initiallyDirty = true): RefreshSignal {
let dirty = initiallyDirty;
return {
markDirty(): void {
dirty = true;
},
consume(): boolean {
const was = dirty;
dirty = false;
return was;
},
get dirty(): boolean {
return dirty;
},
};
}
19 changes: 19 additions & 0 deletions src/registration/store-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ export interface StoreBridgeOptions {
* `/health` and the log; a failed flush is never silently dropped.
*/
onFlushError?(err: unknown, op: DurableWriteOp): void;
/**
* Fired after a durable write SUCCESSFULLY settles, signalling that Deep Lake
* now holds rows the enricher's separate mirror has not seen. The daemon wires
* this to the enricher refresh-signal's `markDirty`, so the enricher refreshes
* on its next tick ONLY after real registration activity - an idle repo never
* marks dirty and the enricher never reads Deep Lake (scale-to-zero). Fail-soft:
* a throwing callback is swallowed so it can never break the serialized write
* queue. Not fired for a parked/failed write (nothing durable landed).
*/
onDurableWrite?(op: DurableWriteOp): void;
}

/**
Expand All @@ -64,6 +74,7 @@ export class StoreBridge implements HiveGraphStore {
private readonly mirror = new InMemoryHiveGraphStore();
private readonly durable: AsyncHiveGraphStore;
private readonly onFlushError: (err: unknown, op: DurableWriteOp) => void;
private readonly onDurableWrite: (op: DurableWriteOp) => void;

/** The serialized durable-write queue. Every enqueue chains onto this so writes flush in order. */
private tail: Promise<void> = Promise.resolve();
Expand All @@ -87,6 +98,7 @@ export class StoreBridge implements HiveGraphStore {
constructor(opts: StoreBridgeOptions) {
this.durable = opts.durable;
this.onFlushError = opts.onFlushError ?? (() => {});
this.onDurableWrite = opts.onDurableWrite ?? (() => {});
}

/**
Expand Down Expand Up @@ -163,6 +175,13 @@ export class StoreBridge implements HiveGraphStore {
() => {
this.pending -= 1;
if (op === "deleteNectar") this.failedIdentityNectars.delete(nectar); // the nectar is gone; nothing left to orphan
// Deep Lake now holds a row the enricher's mirror has not seen: signal a
// refresh (scale-to-zero: fired only on real activity, never when idle).
try {
this.onDurableWrite(op);
} catch {
// fail-soft: a throwing signal must never poison the write queue.
}
},
(err: unknown) => {
this.pending -= 1;
Expand Down
25 changes: 25 additions & 0 deletions test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,31 @@ test("EX-2 a valid NECTAR_PORT is parsed exactly", () => {
});
});

test("NECTAR_ENRICHER_ENABLED defaults to true (enricher on)", () => {
withEnv("NECTAR_ENRICHER_ENABLED", undefined, () => {
assert.equal(resolveConfig().enricherEnabled, true);
});
});

test("NECTAR_ENRICHER_ENABLED kill switch: false/0/off/no disable the enricher", () => {
for (const off of ["false", "0", "off", "no", "FALSE", "Off"]) {
withEnv("NECTAR_ENRICHER_ENABLED", off, () => {
assert.equal(resolveConfig().enricherEnabled, false, `"${off}" should disable the enricher`);
});
}
});

test("NECTAR_ENRICHER_ENABLED true/1/on/yes enable the enricher; garbage falls back to default", () => {
for (const on of ["true", "1", "on", "yes"]) {
withEnv("NECTAR_ENRICHER_ENABLED", on, () => {
assert.equal(resolveConfig().enricherEnabled, true);
});
}
withEnv("NECTAR_ENRICHER_ENABLED", "maybe", () => {
assert.equal(resolveConfig().enricherEnabled, true, "unrecognized value falls back to the default");
});
});

test("EX-2 NECTAR_POLL_INTERVAL_MS below the safe floor throws (no 1 ms tight poll)", () => {
for (const bad of ["0", "1", "500"]) {
withEnv("NECTAR_POLL_INTERVAL_MS", bad, () => {
Expand Down
Loading