@harness-engineering/orchestrator 0.9.2 → 0.11.1

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/index.mjs CHANGED
@@ -3766,6 +3766,7 @@ var AgentRunner = class {
3766
3766
  };
3767
3767
 
3768
3768
  // src/agent/local-model-resolver.ts
3769
+ import { poolStateToCandidates } from "@harness-engineering/local-models";
3769
3770
  var DEFAULT_PROBE_INTERVAL_MS = 3e4;
3770
3771
  var MIN_PROBE_INTERVAL_MS = 1e3;
3771
3772
  var DEFAULT_API_KEY = "lm-studio";
@@ -3822,6 +3823,7 @@ var LocalModelResolver = class {
3822
3823
  endpoint;
3823
3824
  apiKey;
3824
3825
  configured;
3826
+ poolState;
3825
3827
  probeIntervalMs;
3826
3828
  fetchModels;
3827
3829
  logger;
@@ -3850,6 +3852,9 @@ var LocalModelResolver = class {
3850
3852
  this.apiKey = opts.apiKey;
3851
3853
  }
3852
3854
  this.configured = [...opts.configured];
3855
+ if (opts.poolState !== void 0) {
3856
+ this.poolState = opts.poolState;
3857
+ }
3853
3858
  const interval = opts.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
3854
3859
  this.probeIntervalMs = Math.max(MIN_PROBE_INTERVAL_MS, interval);
3855
3860
  const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
@@ -3863,13 +3868,21 @@ var LocalModelResolver = class {
3863
3868
  return {
3864
3869
  available: this.available,
3865
3870
  resolved: this.resolved,
3866
- configured: [...this.configured],
3871
+ configured: this.candidates(),
3867
3872
  detected: [...this.detected],
3868
3873
  lastProbeAt: this.lastProbeAt,
3869
3874
  lastError: this.lastError,
3870
3875
  warnings: [...this.warnings]
3871
3876
  };
3872
3877
  }
3878
+ /**
3879
+ * Effective candidate list. With a poolState port present the list derives
3880
+ * from pool entries (currentScore desc → ollamaName); otherwise the static
3881
+ * `configured` list is returned unchanged (byte-identical to pre-Phase-4).
3882
+ */
3883
+ candidates() {
3884
+ return this.poolState ? poolStateToCandidates(this.poolState.snapshot()) : [...this.configured];
3885
+ }
3873
3886
  onStatusChange(handler) {
3874
3887
  this.listeners.add(handler);
3875
3888
  return () => {
@@ -3893,11 +3906,12 @@ var LocalModelResolver = class {
3893
3906
  this.detected = [...detected];
3894
3907
  this.lastError = null;
3895
3908
  this.lastProbeAt = (/* @__PURE__ */ new Date()).toISOString();
3896
- const match = this.configured.find((id) => detected.includes(id)) ?? null;
3909
+ const candidates = this.candidates();
3910
+ const match = candidates.find((id) => detected.includes(id)) ?? null;
3897
3911
  this.resolved = match;
3898
3912
  this.available = match !== null;
3899
3913
  this.warnings = match ? [] : [
3900
- `No configured local model is loaded. Configured: [${this.configured.join(", ")}]. Detected: [${detected.join(", ")}].`
3914
+ `No configured local model is loaded. Configured: [${candidates.join(", ")}]. Detected: [${detected.join(", ")}].`
3901
3915
  ];
3902
3916
  } catch (err) {
3903
3917
  const message = err instanceof Error ? err.message : "probe failed";
@@ -3946,7 +3960,7 @@ var LocalModelResolver = class {
3946
3960
  return JSON.stringify({
3947
3961
  available: this.available,
3948
3962
  resolved: this.resolved,
3949
- configured: this.configured,
3963
+ configured: this.candidates(),
3950
3964
  detected: this.detected,
3951
3965
  lastError: this.lastError,
3952
3966
  warnings: this.warnings
@@ -3954,6 +3968,20 @@ var LocalModelResolver = class {
3954
3968
  }
3955
3969
  };
3956
3970
 
3971
+ // src/orchestrator.ts
3972
+ import {
3973
+ PoolStateStore,
3974
+ PoolManager,
3975
+ OllamaInstallAdapter,
3976
+ HardwareDetector,
3977
+ RefreshScheduler,
3978
+ runRefreshTick,
3979
+ createNativeRecommender,
3980
+ loadFrozenCandidates,
3981
+ selectCandidates as selectCandidates2
3982
+ } from "@harness-engineering/local-models";
3983
+ import { createModelProposal as createModelProposal2, listProposals as listProposals2 } from "@harness-engineering/core";
3984
+
3957
3985
  // src/agent/config-migration.ts
3958
3986
  var MIGRATION_GUIDE = "docs/guides/multi-backend-routing.md";
3959
3987
  var CASE1_ALWAYS_SUPPRESS = /* @__PURE__ */ new Set(["agent.backend"]);
@@ -8334,10 +8362,10 @@ function checkDiff(diff) {
8334
8362
  function deriveFindings(proposal) {
8335
8363
  const findings = [];
8336
8364
  findings.push(...checkName(proposal.content.name));
8337
- if (proposal.kind === "new-skill") {
8365
+ if (proposal.skillKind === "new-skill") {
8338
8366
  findings.push(...checkSkillYaml(proposal.content.skillYaml ?? ""));
8339
8367
  findings.push(...checkSkillMd(proposal.content.skillMd ?? ""));
8340
- } else if (proposal.kind === "refinement") {
8368
+ } else if (proposal.skillKind === "refinement") {
8341
8369
  findings.push(...checkDiff(proposal.content.diff ?? ""));
8342
8370
  }
8343
8371
  return findings;
@@ -8345,6 +8373,9 @@ function deriveFindings(proposal) {
8345
8373
  async function runGate(projectPath, proposalId) {
8346
8374
  const proposal = await getProposal(projectPath, proposalId);
8347
8375
  if (!proposal) throw new ProposalNotFoundError(proposalId);
8376
+ if (proposal.kind !== "skill") {
8377
+ throw new GateRunError(`gate applies to skill proposals only (got ${proposal.kind})`);
8378
+ }
8348
8379
  if (proposal.status === "approved" || proposal.status === "rejected") {
8349
8380
  throw new GateRunError(
8350
8381
  `proposal ${proposalId} is already ${proposal.status}; cannot re-run the gate`
@@ -8474,8 +8505,11 @@ async function promoteRefinement(projectPath, proposal) {
8474
8505
  async function promote(projectPath, proposalId, decidedBy) {
8475
8506
  const proposal = await getProposal2(projectPath, proposalId);
8476
8507
  if (!proposal) throw new ProposalNotFoundError2(proposalId);
8508
+ if (proposal.kind !== "skill") {
8509
+ throw new PromotionError("only skill proposals are promotable to the catalog");
8510
+ }
8477
8511
  assertGateReady(proposal);
8478
- const out = proposal.kind === "new-skill" ? await promoteNewSkill(projectPath, proposal) : await promoteRefinement(projectPath, proposal);
8512
+ const out = proposal.skillKind === "new-skill" ? await promoteNewSkill(projectPath, proposal) : await promoteRefinement(projectPath, proposal);
8479
8513
  await updateProposal2(projectPath, proposalId, {
8480
8514
  status: "approved",
8481
8515
  decision: {
@@ -8498,7 +8532,7 @@ function emit3(bus, topic, data) {
8498
8532
  function emitProposalCreated(bus, proposal) {
8499
8533
  const data = {
8500
8534
  id: proposal.id,
8501
- kind: proposal.kind,
8535
+ kind: proposal.skillKind,
8502
8536
  name: proposal.content.name,
8503
8537
  proposedBy: proposal.proposedBy,
8504
8538
  justification: proposal.source.justification
@@ -8509,7 +8543,7 @@ function emitProposalCreated(bus, proposal) {
8509
8543
  function emitProposalApproved(bus, proposal) {
8510
8544
  const data = {
8511
8545
  id: proposal.id,
8512
- kind: proposal.kind,
8546
+ kind: proposal.skillKind,
8513
8547
  name: proposal.content.name,
8514
8548
  decidedBy: proposal.decision?.decidedBy ?? "(unknown)"
8515
8549
  };
@@ -8519,7 +8553,7 @@ function emitProposalApproved(bus, proposal) {
8519
8553
  function emitProposalRejected(bus, proposal) {
8520
8554
  const data = {
8521
8555
  id: proposal.id,
8522
- kind: proposal.kind,
8556
+ kind: proposal.skillKind,
8523
8557
  name: proposal.content.name,
8524
8558
  decidedBy: proposal.decision?.decidedBy ?? "(unknown)",
8525
8559
  reason: proposal.decision?.reason ?? "(no reason given)"
@@ -8527,6 +8561,163 @@ function emitProposalRejected(bus, proposal) {
8527
8561
  emit3(bus, "proposal.rejected", data);
8528
8562
  }
8529
8563
 
8564
+ // src/proposals/model-handlers.ts
8565
+ function isInUse(deps, ollamaName) {
8566
+ return deps.isModelInUse ? deps.isModelInUse(ollamaName) : false;
8567
+ }
8568
+ var MODEL_PROPOSAL_TOPIC = "local-models:proposal";
8569
+ var MODEL_POOL_TOPIC = "local-models:pool";
8570
+ function decisionOf(deps, action, reason) {
8571
+ return {
8572
+ decidedAt: (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
8573
+ decidedBy: deps.decidedBy ?? "orchestrator",
8574
+ action,
8575
+ ...reason !== void 0 ? { reason } : {}
8576
+ };
8577
+ }
8578
+ function emitApproved(deps, proposal) {
8579
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8580
+ id: proposal.id,
8581
+ status: "approved",
8582
+ action: proposal.model.action,
8583
+ target: proposal.model.target.ollamaName
8584
+ });
8585
+ }
8586
+ async function deferEviction(deps, proposal, deferredName, event, evicted) {
8587
+ deps.pool.markPendingEviction(deferredName);
8588
+ const updated = await deps.updateProposal(proposal.id, {
8589
+ status: "approved",
8590
+ decision: decisionOf(deps, "approved")
8591
+ });
8592
+ deps.bus.emit(MODEL_POOL_TOPIC, event);
8593
+ emitApproved(deps, proposal);
8594
+ return { status: "approved", proposal: updated, evicted };
8595
+ }
8596
+ async function onApproveModelProposal(deps, proposal) {
8597
+ const { model } = proposal;
8598
+ if (model.action === "evict") {
8599
+ return applyEvictOnly(deps, proposal);
8600
+ }
8601
+ const installResult = await deps.pool.install({
8602
+ hfRepoId: model.target.hfRepoId,
8603
+ ollamaName: model.target.ollamaName,
8604
+ ...model.replaces !== void 0 ? { replaces: model.replaces.ollamaName } : {},
8605
+ ...model.diskImpactGb > 0 ? { sizeOnDiskGb: model.diskImpactGb } : {}
8606
+ });
8607
+ if (installResult.status === "error") {
8608
+ if (installResult.code === "failed_target_missing") {
8609
+ const updated2 = await deps.updateProposal(proposal.id, {
8610
+ status: "failed_target_missing"
8611
+ });
8612
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8613
+ id: proposal.id,
8614
+ status: "failed_target_missing",
8615
+ action: model.action,
8616
+ target: model.target.ollamaName
8617
+ });
8618
+ return { status: "failed_target_missing", proposal: updated2 };
8619
+ }
8620
+ return { status: "error", code: installResult.code, message: installResult.message };
8621
+ }
8622
+ const evicted = [...installResult.evicted];
8623
+ if (model.action === "swap" && model.replaces !== void 0) {
8624
+ const replacesName = model.replaces.ollamaName;
8625
+ if (isInUse(deps, replacesName)) {
8626
+ return deferEviction(
8627
+ deps,
8628
+ proposal,
8629
+ replacesName,
8630
+ {
8631
+ id: proposal.id,
8632
+ action: model.action,
8633
+ installed: model.target.ollamaName,
8634
+ phase: "evict_deferred",
8635
+ deferred: replacesName,
8636
+ ...installResult.evicted.length > 0 ? { evicted: installResult.evicted.map((e) => e.ollamaName) } : {}
8637
+ },
8638
+ evicted
8639
+ );
8640
+ }
8641
+ const evictResult = await deps.pool.evict({ ollamaName: replacesName });
8642
+ if (evictResult.status === "error") {
8643
+ deps.bus.emit(MODEL_POOL_TOPIC, {
8644
+ id: proposal.id,
8645
+ action: model.action,
8646
+ installed: model.target.ollamaName,
8647
+ phase: "swap_evict_failed"
8648
+ });
8649
+ return { status: "error", code: evictResult.code, message: evictResult.message };
8650
+ }
8651
+ if (evictResult.removed !== null) {
8652
+ evicted.push(evictResult.removed);
8653
+ }
8654
+ }
8655
+ const updated = await deps.updateProposal(proposal.id, {
8656
+ status: "approved",
8657
+ decision: decisionOf(deps, "approved")
8658
+ });
8659
+ const evictedNames = [
8660
+ ...installResult.evicted.map((e) => e.ollamaName),
8661
+ ...model.replaces !== void 0 ? [model.replaces.ollamaName] : []
8662
+ ];
8663
+ deps.bus.emit(MODEL_POOL_TOPIC, {
8664
+ id: proposal.id,
8665
+ action: model.action,
8666
+ installed: model.target.ollamaName,
8667
+ ...evictedNames.length > 0 ? { evicted: evictedNames } : {}
8668
+ });
8669
+ emitApproved(deps, proposal);
8670
+ return { status: "approved", proposal: updated, evicted };
8671
+ }
8672
+ async function applyEvictOnly(deps, proposal) {
8673
+ const targetName = proposal.model.target.ollamaName;
8674
+ if (isInUse(deps, targetName)) {
8675
+ return deferEviction(
8676
+ deps,
8677
+ proposal,
8678
+ targetName,
8679
+ { id: proposal.id, action: "evict", phase: "evict_deferred", deferred: targetName },
8680
+ []
8681
+ );
8682
+ }
8683
+ const evictResult = await deps.pool.evict({ ollamaName: targetName });
8684
+ if (evictResult.status === "error") {
8685
+ return { status: "error", code: evictResult.code, message: evictResult.message };
8686
+ }
8687
+ const updated = await deps.updateProposal(proposal.id, {
8688
+ status: "approved",
8689
+ decision: decisionOf(deps, "approved")
8690
+ });
8691
+ deps.bus.emit(MODEL_POOL_TOPIC, {
8692
+ id: proposal.id,
8693
+ action: "evict",
8694
+ // XP-2: `evicted` is string[] at EVERY local-models:pool emit site (swap/add
8695
+ // list multiple removals) — the evict-only path wraps its single removal in
8696
+ // an array too, so consumers never branch on string-vs-array.
8697
+ evicted: [proposal.model.target.ollamaName]
8698
+ });
8699
+ emitApproved(deps, proposal);
8700
+ return {
8701
+ status: "approved",
8702
+ proposal: updated,
8703
+ evicted: evictResult.removed !== null && evictResult.removed !== void 0 ? [evictResult.removed] : []
8704
+ };
8705
+ }
8706
+ async function onRejectModelProposal(deps, proposal, reason) {
8707
+ const updated = await deps.updateProposal(proposal.id, {
8708
+ status: "rejected",
8709
+ decision: decisionOf(deps, "rejected", reason)
8710
+ });
8711
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8712
+ id: proposal.id,
8713
+ status: "rejected",
8714
+ target: proposal.model.target.ollamaName,
8715
+ replaces: proposal.model.replaces?.ollamaName,
8716
+ reason
8717
+ });
8718
+ return updated;
8719
+ }
8720
+
8530
8721
  // src/server/routes/v1/proposals.ts
8531
8722
  var LIST_RE = /^\/api\/v1\/proposals(?:\?.*)?$/;
8532
8723
  var SINGLE_RE = /^\/api\/v1\/proposals\/([^/?]+)(?:\?.*)?$/;
@@ -8552,6 +8743,20 @@ function getDecidedBy(req, deps) {
8552
8743
  const token = req._authToken;
8553
8744
  return token?.id ?? "unknown";
8554
8745
  }
8746
+ function modelHandlerDeps(deps, req) {
8747
+ return {
8748
+ pool: deps.modelPool,
8749
+ bus: deps.bus,
8750
+ // Core's `updateProposal` patch type intersects the skill+model status
8751
+ // enums, which statically excludes model-only statuses like
8752
+ // `failed_target_missing`. The runtime parses through `ProposalSchema` (the
8753
+ // model variant accepts that status), so the cast is sound — only the
8754
+ // intersection type is too narrow.
8755
+ updateProposal: (id, patch) => updateProposal3(deps.projectPath, id, patch),
8756
+ decidedBy: getDecidedBy(req, deps),
8757
+ ...deps.isModelInUse ? { isModelInUse: deps.isModelInUse } : {}
8758
+ };
8759
+ }
8555
8760
  function parseStatusFromQuery(url) {
8556
8761
  const queryIdx = url.indexOf("?");
8557
8762
  if (queryIdx === -1) return void 0;
@@ -8596,11 +8801,31 @@ async function handleRunGate(res, deps, id) {
8596
8801
  }
8597
8802
  }
8598
8803
  async function handleApprove(req, res, deps, id) {
8804
+ const existing = await getProposal3(deps.projectPath, id);
8805
+ if (!existing) {
8806
+ sendJSON8(res, 404, { error: "Proposal not found" });
8807
+ return;
8808
+ }
8809
+ if (existing.kind === "model") {
8810
+ if (!deps.modelPool) {
8811
+ sendJSON8(res, 501, { error: "model proposal handlers not configured" });
8812
+ return;
8813
+ }
8814
+ if (existing.status === "approved" || existing.status === "rejected" || existing.status === "failed_target_missing") {
8815
+ sendJSON8(res, 409, {
8816
+ error: `proposal already ${existing.status}; cannot approve`
8817
+ });
8818
+ return;
8819
+ }
8820
+ const outcome = await onApproveModelProposal(modelHandlerDeps(deps, req), existing);
8821
+ sendJSON8(res, outcome.status === "error" ? 422 : 200, outcome);
8822
+ return;
8823
+ }
8599
8824
  const decidedBy = getDecidedBy(req, deps);
8600
8825
  try {
8601
8826
  const result = await promote(deps.projectPath, id, decidedBy);
8602
8827
  const proposal = await getProposal3(deps.projectPath, id);
8603
- if (proposal) emitProposalApproved(deps.bus, proposal);
8828
+ if (proposal && proposal.kind === "skill") emitProposalApproved(deps.bus, proposal);
8604
8829
  sendJSON8(res, 200, { promotion: result, proposal });
8605
8830
  } catch (err) {
8606
8831
  if (err instanceof ProposalNotFoundError3) {
@@ -8652,6 +8877,19 @@ async function handleReject(req, res, deps, id) {
8652
8877
  });
8653
8878
  return;
8654
8879
  }
8880
+ if (proposal.kind === "model") {
8881
+ if (!deps.modelPool) {
8882
+ sendJSON8(res, 501, { error: "model proposal handlers not configured" });
8883
+ return;
8884
+ }
8885
+ const updated2 = await onRejectModelProposal(
8886
+ modelHandlerDeps(deps, req),
8887
+ proposal,
8888
+ parsed.data.reason
8889
+ );
8890
+ sendJSON8(res, 200, updated2);
8891
+ return;
8892
+ }
8655
8893
  const decidedBy = getDecidedBy(req, deps);
8656
8894
  const updated = await updateProposal3(deps.projectPath, id, {
8657
8895
  status: "rejected",
@@ -8662,7 +8900,7 @@ async function handleReject(req, res, deps, id) {
8662
8900
  reason: parsed.data.reason
8663
8901
  }
8664
8902
  });
8665
- emitProposalRejected(deps.bus, updated);
8903
+ if (updated.kind === "skill") emitProposalRejected(deps.bus, updated);
8666
8904
  sendJSON8(res, 200, updated);
8667
8905
  }
8668
8906
  async function handleEdit(req, res, deps, id) {
@@ -8690,6 +8928,10 @@ async function handleEdit(req, res, deps, id) {
8690
8928
  sendJSON8(res, 404, { error: "Proposal not found" });
8691
8929
  return;
8692
8930
  }
8931
+ if (existing.kind !== "skill") {
8932
+ sendJSON8(res, 422, { error: "edit applies to skill proposals only" });
8933
+ return;
8934
+ }
8693
8935
  if (existing.status === "approved" || existing.status === "rejected") {
8694
8936
  sendJSON8(res, 409, {
8695
8937
  error: `proposal already ${existing.status}; cannot edit`
@@ -8755,17 +8997,331 @@ function handleV1ProposalsRoute(req, res, deps) {
8755
8997
  return false;
8756
8998
  }
8757
8999
 
9000
+ // src/server/routes/v1/local-models.ts
9001
+ import {
9002
+ isTickHardFailure
9003
+ } from "@harness-engineering/local-models";
9004
+ var REFRESH_RE = /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/;
9005
+ var HARDWARE_RE = /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/;
9006
+ var POOL_RE = /^\/api\/v1\/local-models\/pool(?:\?.*)?$/;
9007
+ var RECS_RE = /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/;
9008
+ var PROPOSALS_RE = /^\/api\/v1\/local-models\/proposals(?:\?.*)?$/;
9009
+ var RECOMMENDATION_PROFILES = ["general", "coding", "reasoning"];
9010
+ function sendJSON9(res, status, body) {
9011
+ res.writeHead(status, { "Content-Type": "application/json" });
9012
+ res.end(JSON.stringify(body));
9013
+ }
9014
+ function handleV1LocalModelsRoute(req, res, deps) {
9015
+ const url = req.url ?? "";
9016
+ const method = req.method ?? "GET";
9017
+ if (method === "GET") {
9018
+ if (HARDWARE_RE.test(url)) {
9019
+ void runGet(res, () => handleHardware(res, deps));
9020
+ return true;
9021
+ }
9022
+ if (POOL_RE.test(url)) {
9023
+ void runGet(res, () => handlePool(res, deps));
9024
+ return true;
9025
+ }
9026
+ if (RECS_RE.test(url)) {
9027
+ void runGet(res, () => handleRecommendations(res, url, deps));
9028
+ return true;
9029
+ }
9030
+ if (PROPOSALS_RE.test(url)) {
9031
+ void runGet(res, () => handleProposals(res, deps));
9032
+ return true;
9033
+ }
9034
+ return false;
9035
+ }
9036
+ if (method !== "POST" || !REFRESH_RE.test(url)) return false;
9037
+ const scheduler = deps.getRefreshScheduler();
9038
+ if (scheduler === null) {
9039
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9040
+ return true;
9041
+ }
9042
+ void runForceRefresh(res, scheduler, deps);
9043
+ return true;
9044
+ }
9045
+ async function runGet(res, handler) {
9046
+ try {
9047
+ await handler();
9048
+ } catch (err) {
9049
+ sendJSON9(res, 500, {
9050
+ error: "local-models route failed",
9051
+ detail: err instanceof Error ? err.message : "unknown"
9052
+ });
9053
+ }
9054
+ }
9055
+ async function handleHardware(res, deps) {
9056
+ const pending = deps.getHardwareProfile?.() ?? null;
9057
+ if (pending === null) {
9058
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9059
+ return;
9060
+ }
9061
+ sendJSON9(res, 200, await pending);
9062
+ }
9063
+ async function handlePool(res, deps) {
9064
+ const pool = deps.getModelPool?.() ?? null;
9065
+ if (pool === null) {
9066
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9067
+ return;
9068
+ }
9069
+ sendJSON9(res, 200, pool.viewState?.() ?? pool.snapshot());
9070
+ }
9071
+ var MAX_RECOMMENDATION_TOP = 100;
9072
+ async function handleRecommendations(res, url, deps) {
9073
+ if (deps.getRecommendations === void 0) {
9074
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9075
+ return;
9076
+ }
9077
+ const params = new URLSearchParams(url.split("?")[1] ?? "");
9078
+ const topRaw = params.get("top");
9079
+ let top = 10;
9080
+ if (topRaw !== null) {
9081
+ const n = Number(topRaw);
9082
+ if (!Number.isInteger(n) || n <= 0) {
9083
+ sendJSON9(res, 400, { error: "invalid top: expected a positive integer" });
9084
+ return;
9085
+ }
9086
+ if (n > MAX_RECOMMENDATION_TOP) {
9087
+ sendJSON9(res, 400, { error: `invalid top: exceeds maximum of ${MAX_RECOMMENDATION_TOP}` });
9088
+ return;
9089
+ }
9090
+ top = n;
9091
+ }
9092
+ const profileRaw = params.get("profile");
9093
+ let profile = "general";
9094
+ if (profileRaw !== null) {
9095
+ if (!isRecommendationProfile(profileRaw)) {
9096
+ sendJSON9(res, 400, {
9097
+ error: `invalid profile: expected one of ${RECOMMENDATION_PROFILES.join(", ")}`
9098
+ });
9099
+ return;
9100
+ }
9101
+ profile = profileRaw;
9102
+ }
9103
+ sendJSON9(res, 200, await deps.getRecommendations({ top, profile }));
9104
+ }
9105
+ async function handleProposals(res, deps) {
9106
+ if (deps.listModelProposals === void 0) {
9107
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9108
+ return;
9109
+ }
9110
+ sendJSON9(res, 200, await deps.listModelProposals());
9111
+ }
9112
+ function isRecommendationProfile(value) {
9113
+ return RECOMMENDATION_PROFILES.includes(value);
9114
+ }
9115
+ async function runForceRefresh(res, scheduler, deps) {
9116
+ let result;
9117
+ try {
9118
+ result = await scheduler.forceRefresh();
9119
+ } catch (err) {
9120
+ sendJSON9(res, 500, {
9121
+ error: "refresh tick failed",
9122
+ detail: err instanceof Error ? err.message : "unknown"
9123
+ });
9124
+ return;
9125
+ }
9126
+ if (isTickHardFailure(result)) {
9127
+ sendJSON9(res, 503, {
9128
+ error: "refresh hard failure: HuggingFace unreachable and no benchmark snapshot loaded",
9129
+ emitted: result.proposalsEmitted,
9130
+ warnings: result.warnings,
9131
+ errors: result.errors
9132
+ });
9133
+ return;
9134
+ }
9135
+ const proposals = deps.listModelProposals ? await deps.listModelProposals() : [];
9136
+ sendJSON9(res, 200, {
9137
+ emitted: result.proposalsEmitted,
9138
+ reconciledRemoved: result.reconciledRemoved,
9139
+ proposals,
9140
+ warnings: result.warnings
9141
+ });
9142
+ }
9143
+
9144
+ // src/server/routes/v1/local-models-pool-mutation.ts
9145
+ import { createModelProposal, updateProposal as updateProposal4 } from "@harness-engineering/core";
9146
+ var INSTALL_RE = /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/;
9147
+ var REMOVE_RE = /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/;
9148
+ var RESOLVE_TOP = 50;
9149
+ function sendJSON10(res, status, body) {
9150
+ res.writeHead(status, { "Content-Type": "application/json" });
9151
+ res.end(JSON.stringify(body));
9152
+ }
9153
+ function decidedByOf(deps, req) {
9154
+ if (deps.getDecidedBy) return deps.getDecidedBy(req);
9155
+ const token = req._authToken;
9156
+ return token?.id ?? "operator";
9157
+ }
9158
+ function handlerDeps(deps, req, pool) {
9159
+ return {
9160
+ pool,
9161
+ bus: deps.bus,
9162
+ updateProposal: (id, patch) => updateProposal4(deps.projectPath, id, patch),
9163
+ decidedBy: decidedByOf(deps, req),
9164
+ ...deps.isModelInUse ? { isModelInUse: deps.isModelInUse } : {}
9165
+ };
9166
+ }
9167
+ async function readJsonBody(req) {
9168
+ try {
9169
+ const raw = await readBody(req);
9170
+ if (raw.length === 0) return {};
9171
+ const parsed = JSON.parse(raw);
9172
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
9173
+ } catch {
9174
+ return void 0;
9175
+ }
9176
+ }
9177
+ async function handleInstall(req, res, deps) {
9178
+ const pool = deps.getModelPool();
9179
+ if (pool === null) return sendJSON10(res, 503, { error: "LMLM disabled" });
9180
+ const body = await readJsonBody(req);
9181
+ if (body === void 0) return sendJSON10(res, 400, { error: "invalid JSON body" });
9182
+ const hfRepoId = typeof body.hfRepoId === "string" ? body.hfRepoId : "";
9183
+ if (hfRepoId.length === 0) return sendJSON10(res, 400, { error: "hfRepoId is required" });
9184
+ const quant = typeof body.quant === "string" ? body.quant : void 0;
9185
+ if (!deps.getRecommendations) {
9186
+ return sendJSON10(res, 503, { error: "recommendations unavailable" });
9187
+ }
9188
+ const ranked = await deps.getRecommendations({ top: RESOLVE_TOP, profile: "general" });
9189
+ const match = ranked.find(
9190
+ (r) => r.hfRepoId === hfRepoId && (quant === void 0 || r.quant === quant)
9191
+ );
9192
+ if (!match) {
9193
+ return sendJSON10(res, 404, {
9194
+ error: `no recommendation for ${hfRepoId}${quant ? ` @ ${quant}` : ""}`
9195
+ });
9196
+ }
9197
+ if (!match.ollamaName) {
9198
+ return sendJSON10(res, 422, { error: `${hfRepoId} has no known Ollama mirror to install` });
9199
+ }
9200
+ const content = {
9201
+ action: "add",
9202
+ target: { hfRepoId: match.hfRepoId, ollamaName: match.ollamaName },
9203
+ scoreDelta: match.score,
9204
+ justification: {
9205
+ summary: `Operator-initiated install of ${match.ollamaName} (${match.quant}).`,
9206
+ benchmarkBasis: [],
9207
+ hardwareFit: match.fitsHardware ? "fits detected hardware" : "may not fit detected hardware",
9208
+ evidence: match.evidence,
9209
+ freshness: `snapshot ${match.benchmarkSnapshot}`
9210
+ },
9211
+ // 0 → the installer resolves the true on-disk size via `inspect` before the
9212
+ // budget check, rather than trusting an estimate.
9213
+ diskImpactGb: 0
9214
+ };
9215
+ const record = await createModelProposal(deps.projectPath, content, {
9216
+ proposedBy: decidedByOf(deps, req)
9217
+ });
9218
+ const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9219
+ if (outcome.status === "approved") {
9220
+ const result = {
9221
+ disposition: "installed",
9222
+ proposalId: record.id,
9223
+ evicted: outcome.evicted.map((e) => e.ollamaName)
9224
+ };
9225
+ return sendJSON10(res, 200, result);
9226
+ }
9227
+ if (outcome.status === "failed_target_missing") {
9228
+ return sendJSON10(res, 404, {
9229
+ error: `${hfRepoId} is no longer available on HuggingFace`,
9230
+ proposalId: record.id
9231
+ });
9232
+ }
9233
+ const status = outcome.code === "not_allowed" || outcome.code === "budget_exceeded" ? 409 : 502;
9234
+ return sendJSON10(res, status, {
9235
+ error: outcome.message,
9236
+ code: outcome.code,
9237
+ proposalId: record.id
9238
+ });
9239
+ }
9240
+ async function handleRemove(req, res, deps) {
9241
+ const pool = deps.getModelPool();
9242
+ if (pool === null) return sendJSON10(res, 503, { error: "LMLM disabled" });
9243
+ const body = await readJsonBody(req);
9244
+ if (body === void 0) return sendJSON10(res, 400, { error: "invalid JSON body" });
9245
+ const ollamaName = typeof body.ollamaName === "string" ? body.ollamaName : "";
9246
+ if (ollamaName.length === 0) return sendJSON10(res, 400, { error: "ollamaName is required" });
9247
+ const entry = pool.snapshot().entries.find((e) => e.ollamaName === ollamaName);
9248
+ if (!entry) return sendJSON10(res, 404, { error: `${ollamaName} is not in the pool` });
9249
+ const content = {
9250
+ action: "evict",
9251
+ target: { hfRepoId: entry.hfRepoId, ollamaName },
9252
+ scoreDelta: 0,
9253
+ justification: {
9254
+ summary: `Operator-initiated removal of ${ollamaName}.`,
9255
+ benchmarkBasis: [],
9256
+ hardwareFit: "",
9257
+ evidence: "operator",
9258
+ freshness: ""
9259
+ },
9260
+ diskImpactGb: entry.sizeOnDiskGb
9261
+ };
9262
+ const record = await createModelProposal(deps.projectPath, content, {
9263
+ proposedBy: decidedByOf(deps, req)
9264
+ });
9265
+ const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9266
+ if (outcome.status === "error") {
9267
+ return sendJSON10(res, 502, {
9268
+ error: outcome.message,
9269
+ code: outcome.code,
9270
+ proposalId: record.id
9271
+ });
9272
+ }
9273
+ if (outcome.status === "failed_target_missing") {
9274
+ return sendJSON10(res, 500, {
9275
+ error: "unexpected failed_target_missing on evict",
9276
+ proposalId: record.id
9277
+ });
9278
+ }
9279
+ if (outcome.evicted.length > 0) {
9280
+ const result2 = {
9281
+ disposition: "removed",
9282
+ proposalId: record.id,
9283
+ evicted: outcome.evicted.map((e) => e.ollamaName)
9284
+ };
9285
+ return sendJSON10(res, 200, result2);
9286
+ }
9287
+ const stillPresent = pool.snapshot().entries.some((e) => e.ollamaName === ollamaName);
9288
+ if (stillPresent) {
9289
+ const result2 = {
9290
+ disposition: "deferred",
9291
+ proposalId: record.id,
9292
+ evicted: [],
9293
+ message: "model is in use; it will be removed after the current run"
9294
+ };
9295
+ return sendJSON10(res, 202, result2);
9296
+ }
9297
+ const result = { disposition: "removed", proposalId: record.id, evicted: [] };
9298
+ return sendJSON10(res, 200, result);
9299
+ }
9300
+ function handleV1LocalModelsMutationRoute(req, res, deps) {
9301
+ const url = req.url ?? "";
9302
+ if ((req.method ?? "GET") !== "POST") return false;
9303
+ if (INSTALL_RE.test(url)) {
9304
+ void handleInstall(req, res, deps);
9305
+ return true;
9306
+ }
9307
+ if (REMOVE_RE.test(url)) {
9308
+ void handleRemove(req, res, deps);
9309
+ return true;
9310
+ }
9311
+ return false;
9312
+ }
9313
+
8758
9314
  // src/server/routes/v1/routing.ts
8759
9315
  import { z as z14 } from "zod";
8760
9316
  var CONFIG_RE = /^\/api\/v1\/routing\/config(?:\?.*)?$/;
8761
9317
  var DECISIONS_RE = /^\/api\/v1\/routing\/decisions(?:\?.*)?$/;
8762
9318
  var TRACE_RE = /^\/api\/v1\/routing\/trace(?:\?.*)?$/;
8763
- function sendJSON9(res, status, body) {
9319
+ function sendJSON11(res, status, body) {
8764
9320
  res.writeHead(status, { "Content-Type": "application/json" });
8765
9321
  res.end(JSON.stringify(body));
8766
9322
  }
8767
9323
  function unavailable(res) {
8768
- sendJSON9(res, 503, { error: "BackendRouter not available" });
9324
+ sendJSON11(res, 503, { error: "BackendRouter not available" });
8769
9325
  return true;
8770
9326
  }
8771
9327
  function resolveChain(value, backends) {
@@ -8802,7 +9358,7 @@ function buildResolvedChains(routing, backends) {
8802
9358
  }
8803
9359
  function handleConfig(res, deps) {
8804
9360
  if (!deps.router || !deps.routing || !deps.backends) return unavailable(res);
8805
- sendJSON9(res, 200, {
9361
+ sendJSON11(res, 200, {
8806
9362
  routing: deps.routing,
8807
9363
  resolvedChains: buildResolvedChains(deps.routing, deps.backends),
8808
9364
  backends: Object.keys(deps.backends)
@@ -8830,7 +9386,7 @@ function parseDecisionsQuery(url) {
8830
9386
  function handleDecisions(req, res, deps) {
8831
9387
  if (!deps.bus) return unavailable(res);
8832
9388
  const filter = parseDecisionsQuery(req.url ?? "");
8833
- sendJSON9(res, 200, { decisions: deps.bus.recent(filter) });
9389
+ sendJSON11(res, 200, { decisions: deps.bus.recent(filter) });
8834
9390
  return true;
8835
9391
  }
8836
9392
  var UseCaseSchema = z14.discriminatedUnion("kind", [
@@ -8862,19 +9418,19 @@ async function handleTrace(req, res, deps) {
8862
9418
  try {
8863
9419
  raw = await readBody(req);
8864
9420
  } catch {
8865
- sendJSON9(res, 400, { error: "body read failed" });
9421
+ sendJSON11(res, 400, { error: "body read failed" });
8866
9422
  return true;
8867
9423
  }
8868
9424
  let parsed;
8869
9425
  try {
8870
9426
  parsed = JSON.parse(raw);
8871
9427
  } catch {
8872
- sendJSON9(res, 400, { error: "invalid JSON body" });
9428
+ sendJSON11(res, 400, { error: "invalid JSON body" });
8873
9429
  return true;
8874
9430
  }
8875
9431
  const r = TraceBodySchema.safeParse(parsed);
8876
9432
  if (!r.success) {
8877
- sendJSON9(res, 400, { error: r.error.message });
9433
+ sendJSON11(res, 400, { error: r.error.message });
8878
9434
  return true;
8879
9435
  }
8880
9436
  const opts = r.data.invocationOverride !== void 0 ? { invocationOverride: r.data.invocationOverride } : void 0;
@@ -8887,9 +9443,9 @@ async function handleTrace(req, res, deps) {
8887
9443
  r.data.useCase,
8888
9444
  opts
8889
9445
  );
8890
- sendJSON9(res, 200, { decision, def: { type: def.type } });
9446
+ sendJSON11(res, 200, { decision, def: { type: def.type } });
8891
9447
  } catch (err) {
8892
- sendJSON9(res, 500, { error: String(err) });
9448
+ sendJSON11(res, 500, { error: String(err) });
8893
9449
  }
8894
9450
  return true;
8895
9451
  }
@@ -9130,7 +9686,7 @@ var CreateBodySchema = z16.object({
9130
9686
  tenantId: z16.string().optional(),
9131
9687
  expiresAt: z16.string().datetime().optional()
9132
9688
  });
9133
- function sendJSON10(res, status, body) {
9689
+ function sendJSON12(res, status, body) {
9134
9690
  res.writeHead(status, { "Content-Type": "application/json" });
9135
9691
  res.end(JSON.stringify(body));
9136
9692
  }
@@ -9140,19 +9696,19 @@ async function handlePost(req, res, store) {
9140
9696
  raw = await readBody(req);
9141
9697
  } catch (err) {
9142
9698
  const msg = err instanceof Error ? err.message : "Failed to read body";
9143
- sendJSON10(res, 413, { error: msg });
9699
+ sendJSON12(res, 413, { error: msg });
9144
9700
  return;
9145
9701
  }
9146
9702
  let json;
9147
9703
  try {
9148
9704
  json = JSON.parse(raw);
9149
9705
  } catch {
9150
- sendJSON10(res, 400, { error: "Invalid JSON body" });
9706
+ sendJSON12(res, 400, { error: "Invalid JSON body" });
9151
9707
  return;
9152
9708
  }
9153
9709
  const parsed = CreateBodySchema.safeParse(json);
9154
9710
  if (!parsed.success) {
9155
- sendJSON10(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9711
+ sendJSON12(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9156
9712
  return;
9157
9713
  }
9158
9714
  try {
@@ -9165,37 +9721,37 @@ async function handlePost(req, res, store) {
9165
9721
  if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
9166
9722
  const result = await store.create(input);
9167
9723
  const publicRecord = AuthTokenPublicSchema.parse(result.record);
9168
- sendJSON10(res, 200, {
9724
+ sendJSON12(res, 200, {
9169
9725
  ...publicRecord,
9170
9726
  token: result.token
9171
9727
  });
9172
9728
  } catch (err) {
9173
9729
  const msg = err instanceof Error ? err.message : "Failed to create token";
9174
9730
  if (msg.includes("already exists")) {
9175
- sendJSON10(res, 409, { error: msg });
9731
+ sendJSON12(res, 409, { error: msg });
9176
9732
  return;
9177
9733
  }
9178
- sendJSON10(res, 500, { error: "Internal error creating token" });
9734
+ sendJSON12(res, 500, { error: "Internal error creating token" });
9179
9735
  }
9180
9736
  }
9181
9737
  async function handleList3(res, store) {
9182
9738
  try {
9183
9739
  const list = await store.list();
9184
- sendJSON10(res, 200, list);
9740
+ sendJSON12(res, 200, list);
9185
9741
  } catch {
9186
- sendJSON10(res, 500, { error: "Internal error listing tokens" });
9742
+ sendJSON12(res, 500, { error: "Internal error listing tokens" });
9187
9743
  }
9188
9744
  }
9189
9745
  async function handleDelete2(res, store, id) {
9190
9746
  try {
9191
9747
  const ok = await store.revoke(id);
9192
9748
  if (!ok) {
9193
- sendJSON10(res, 404, { error: "Token not found" });
9749
+ sendJSON12(res, 404, { error: "Token not found" });
9194
9750
  return;
9195
9751
  }
9196
- sendJSON10(res, 200, { deleted: true });
9752
+ sendJSON12(res, 200, { deleted: true });
9197
9753
  } catch {
9198
- sendJSON10(res, 500, { error: "Internal error revoking token" });
9754
+ sendJSON12(res, 500, { error: "Internal error revoking token" });
9199
9755
  }
9200
9756
  }
9201
9757
  var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
@@ -9220,12 +9776,12 @@ function handleAuthRoute(req, res, store) {
9220
9776
  return true;
9221
9777
  }
9222
9778
  }
9223
- sendJSON10(res, 405, { error: "Method not allowed" });
9779
+ sendJSON12(res, 405, { error: "Method not allowed" });
9224
9780
  return true;
9225
9781
  }
9226
9782
 
9227
9783
  // src/server/routes/local-model.ts
9228
- function sendJSON11(res, status, body) {
9784
+ function sendJSON13(res, status, body) {
9229
9785
  res.writeHead(status, { "Content-Type": "application/json" });
9230
9786
  res.end(JSON.stringify(body));
9231
9787
  }
@@ -9233,30 +9789,30 @@ function handleLocalModelRoute(req, res, getStatus) {
9233
9789
  const { method, url } = req;
9234
9790
  if (url !== "/api/v1/local-model/status") return false;
9235
9791
  if (method !== "GET") {
9236
- sendJSON11(res, 405, { error: "Method not allowed" });
9792
+ sendJSON13(res, 405, { error: "Method not allowed" });
9237
9793
  return true;
9238
9794
  }
9239
9795
  if (!getStatus) {
9240
- sendJSON11(res, 503, { error: "Local backend not configured" });
9796
+ sendJSON13(res, 503, { error: "Local backend not configured" });
9241
9797
  return true;
9242
9798
  }
9243
9799
  const status = getStatus();
9244
9800
  if (!status) {
9245
- sendJSON11(res, 503, { error: "Local backend not configured" });
9801
+ sendJSON13(res, 503, { error: "Local backend not configured" });
9246
9802
  return true;
9247
9803
  }
9248
- sendJSON11(res, 200, status);
9804
+ sendJSON13(res, 200, status);
9249
9805
  return true;
9250
9806
  }
9251
9807
  function handleLocalModelsRoute(req, res, getStatuses) {
9252
9808
  const { method, url } = req;
9253
9809
  if (url !== "/api/v1/local-models/status") return false;
9254
9810
  if (method !== "GET") {
9255
- sendJSON11(res, 405, { error: "Method not allowed" });
9811
+ sendJSON13(res, 405, { error: "Method not allowed" });
9256
9812
  return true;
9257
9813
  }
9258
9814
  const statuses = getStatuses ? getStatuses() : [];
9259
- sendJSON11(res, 200, statuses);
9815
+ sendJSON13(res, 200, statuses);
9260
9816
  return true;
9261
9817
  }
9262
9818
 
@@ -9623,6 +10179,60 @@ var V1_BRIDGE_ROUTES = [
9623
10179
  scope: "read-telemetry",
9624
10180
  description: "Prompt-cache hit/miss snapshot (rolling window)."
9625
10181
  },
10182
+ // ── LMLM Phase 6 — force-refresh (background scheduler tick, out of band) ──
10183
+ // Reuses `manage-proposals`: a forced refresh emits model proposals, so the
10184
+ // same write scope that governs approve/reject governs triggering the tick.
10185
+ {
10186
+ method: "POST",
10187
+ pattern: /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/,
10188
+ scope: "manage-proposals",
10189
+ description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
10190
+ },
10191
+ // ── LMLM dashboard pool mutation — operator-initiated install/remove ──
10192
+ // Modeled as auto-approved model proposals, so the same `manage-proposals`
10193
+ // write scope that governs approve/reject governs these too.
10194
+ {
10195
+ method: "POST",
10196
+ pattern: /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/,
10197
+ scope: "manage-proposals",
10198
+ description: "Install a recommended model into the local pool (auto-approved proposal)."
10199
+ },
10200
+ {
10201
+ method: "POST",
10202
+ pattern: /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/,
10203
+ scope: "manage-proposals",
10204
+ description: "Remove a model from the local pool (auto-approved proposal)."
10205
+ },
10206
+ // ── LMLM Phase 7 — read surface (hardware/pool/recommendations/proposals) ──
10207
+ // Load-bearing: `local-models` is in `V1_WRAPPABLE`, so without these entries
10208
+ // the `/api/v1` rewrite shim would rewrite `/api/v1/local-models/<name>` →
10209
+ // `/api/local-models/<name>` and misroute it to the legacy status handler.
10210
+ // `isV1Bridge` short-circuits the rewrite; `requiredBridgeScope` supplies the
10211
+ // default-deny read scope. All four are read-only observability (`read-status`).
10212
+ {
10213
+ method: "GET",
10214
+ pattern: /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/,
10215
+ scope: "read-status",
10216
+ description: "Current detected HardwareProfile (RAM/VRAM/GPU)."
10217
+ },
10218
+ {
10219
+ method: "GET",
10220
+ pattern: /^\/api\/v1\/local-models\/pool(?:\?.*)?$/,
10221
+ scope: "read-status",
10222
+ description: "Live PoolState view, including transient pendingEviction flags."
10223
+ },
10224
+ {
10225
+ method: "GET",
10226
+ pattern: /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/,
10227
+ scope: "read-status",
10228
+ description: "Hardware-ranked model recommendations (top/profile query params)."
10229
+ },
10230
+ {
10231
+ method: "GET",
10232
+ pattern: /^\/api\/v1\/local-models\/proposals(?:\?.*)?$/,
10233
+ scope: "read-status",
10234
+ description: "Open model-kind proposals (pending review queue)."
10235
+ },
9626
10236
  // ── Spec B Phase 5 routing observability ──
9627
10237
  // D-OP-1: all three reuse `read-telemetry` — matches the cacheMetrics
9628
10238
  // precedent (read-only observability). A dedicated `read-routing`
@@ -9765,7 +10375,19 @@ var OrchestratorServer = class {
9765
10375
  getRoutingDecisionBusFn = null;
9766
10376
  getRoutingConfigFn = null;
9767
10377
  getBackendsFn = null;
10378
+ // LMLM Phase 6 — live model pool + refresh scheduler accessors.
10379
+ getModelPoolFn = null;
10380
+ getRefreshSchedulerFn = null;
10381
+ // LMLM Phase 7 — hardware / recommendations / model-proposal read accessors.
10382
+ getHardwareProfileFn = null;
10383
+ getRecommendationsFn = null;
10384
+ listModelProposalsFn = null;
10385
+ // LMLM Phase 7 / S1 — in-use probe threaded into kind:'model' approve.
10386
+ isModelInUseFn = null;
9768
10387
  routingDecisionUnsubscribe = null;
10388
+ // LMLM Phase 7 — bus→WS fan-out listeners for the model proposal/pool topics.
10389
+ modelProposalListener = null;
10390
+ modelPoolListener = null;
9769
10391
  recorder = null;
9770
10392
  planWatcher = null;
9771
10393
  tokenStore;
@@ -9810,6 +10432,12 @@ var OrchestratorServer = class {
9810
10432
  this.getRoutingDecisionBusFn = deps?.getRoutingDecisionBus ?? null;
9811
10433
  this.getRoutingConfigFn = deps?.getRoutingConfig ?? null;
9812
10434
  this.getBackendsFn = deps?.getBackends ?? null;
10435
+ this.getModelPoolFn = deps?.getModelPool ?? null;
10436
+ this.getRefreshSchedulerFn = deps?.getRefreshScheduler ?? null;
10437
+ this.getHardwareProfileFn = deps?.getHardwareProfile ?? null;
10438
+ this.getRecommendationsFn = deps?.getRecommendations ?? null;
10439
+ this.listModelProposalsFn = deps?.listModelProposals ?? null;
10440
+ this.isModelInUseFn = deps?.isModelInUse ?? null;
9813
10441
  }
9814
10442
  wireEvents() {
9815
10443
  this.stateChangeListener = (snapshot) => {
@@ -9826,6 +10454,10 @@ var OrchestratorServer = class {
9826
10454
  this.broadcaster.broadcast("routing:decision", decision);
9827
10455
  });
9828
10456
  }
10457
+ this.modelProposalListener = (data) => this.broadcaster.broadcast(MODEL_PROPOSAL_TOPIC, data);
10458
+ this.modelPoolListener = (data) => this.broadcaster.broadcast(MODEL_POOL_TOPIC, data);
10459
+ this.orchestrator.on(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
10460
+ this.orchestrator.on(MODEL_POOL_TOPIC, this.modelPoolListener);
9829
10461
  }
9830
10462
  /**
9831
10463
  * Broadcast a new interaction to all WebSocket clients.
@@ -9994,9 +10626,38 @@ var OrchestratorServer = class {
9994
10626
  // upstream by V1_BRIDGE_ROUTES; this dispatcher only handles
9995
10627
  // business logic. `projectPath` defaults to process.cwd() — that is
9996
10628
  // where `.harness/proposals/` lives in every deployment we ship.
9997
- (req, res) => handleV1ProposalsRoute(req, res, {
10629
+ (req, res) => {
10630
+ const modelPool = this.getModelPoolFn?.() ?? null;
10631
+ return handleV1ProposalsRoute(req, res, {
10632
+ projectPath: this.projectPath,
10633
+ bus: this.orchestrator,
10634
+ ...modelPool ? { modelPool } : {},
10635
+ // S1: thread the in-use probe so an approved swap/evict of a model an
10636
+ // agent could be using is deferred (marked pendingEviction) not applied.
10637
+ ...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
10638
+ });
10639
+ },
10640
+ // LMLM dashboard pool mutation — POST /pool/install + /pool/remove.
10641
+ // Operator-initiated install/remove via auto-approved proposals; reuses
10642
+ // the same mutation pool + bus + in-use probe the proposals route uses.
10643
+ // Registered before the read surface so /pool/install|remove match first.
10644
+ (req, res) => handleV1LocalModelsMutationRoute(req, res, {
9998
10645
  projectPath: this.projectPath,
9999
- bus: this.orchestrator
10646
+ bus: this.orchestrator,
10647
+ getModelPool: () => this.getModelPoolFn?.() ?? null,
10648
+ ...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
10649
+ ...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
10650
+ }),
10651
+ // LMLM Phase 6/7 — POST /refresh + the GET read surface
10652
+ // (hardware/pool/recommendations/proposals). Registered before the
10653
+ // chat-proxy fallback so it owns the path. Each accessor is spread in
10654
+ // only when configured so absent ones surface as 503 (LMLM disabled).
10655
+ (req, res) => handleV1LocalModelsRoute(req, res, {
10656
+ getRefreshScheduler: this.getRefreshSchedulerFn ?? (() => null),
10657
+ getModelPool: () => this.getModelPoolFn?.() ?? null,
10658
+ ...this.getHardwareProfileFn ? { getHardwareProfile: this.getHardwareProfileFn } : {},
10659
+ ...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
10660
+ ...this.listModelProposalsFn ? { listModelProposals: this.listModelProposalsFn } : {}
10000
10661
  }),
10001
10662
  // Chat proxy route (spawns Claude Code CLI — no API key required)
10002
10663
  (req, res) => handleChatProxyRoute(req, res, this.claudeCommand)
@@ -10100,6 +10761,14 @@ var OrchestratorServer = class {
10100
10761
  this.routingDecisionUnsubscribe();
10101
10762
  this.routingDecisionUnsubscribe = null;
10102
10763
  }
10764
+ if (this.modelProposalListener) {
10765
+ this.orchestrator.removeListener(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
10766
+ this.modelProposalListener = null;
10767
+ }
10768
+ if (this.modelPoolListener) {
10769
+ this.orchestrator.removeListener(MODEL_POOL_TOPIC, this.modelPoolListener);
10770
+ this.modelPoolListener = null;
10771
+ }
10103
10772
  if (this.planWatcher) {
10104
10773
  this.planWatcher.stop();
10105
10774
  this.planWatcher = null;
@@ -11015,8 +11684,50 @@ var ENVELOPE_DERIVERS = {
11015
11684
  summary: truncate(data.reason ?? "No reason provided.", 240),
11016
11685
  severity: "warning"
11017
11686
  };
11018
- }
11687
+ },
11688
+ // LMLM Phase 7 — model-proposal lifecycle. Delegated to a status-keyed
11689
+ // lookup (below) so each status branch is its own small function and the
11690
+ // deriver stays under the cyclomatic-complexity gate.
11691
+ "local-models.proposal": (event) => deriveModelProposalEnvelope(event)
11692
+ };
11693
+ var MODEL_PROPOSAL_ENVELOPES = {
11694
+ created: (data) => {
11695
+ const label = `${data.action ?? "change"} ${data.target ?? "(unknown model)"}`;
11696
+ return {
11697
+ title: `New model proposal: ${label}`,
11698
+ summary: `A model proposal (${label}) is awaiting review.`,
11699
+ severity: "info"
11700
+ };
11701
+ },
11702
+ approved: (data) => {
11703
+ const label = `${data.action ?? "change"} ${data.target ?? "(unknown model)"}`;
11704
+ return {
11705
+ title: `Model proposal approved: ${label}`,
11706
+ summary: `The model proposal (${label}) was approved and applied to the pool.`,
11707
+ severity: "success"
11708
+ };
11709
+ },
11710
+ rejected: (data) => ({
11711
+ title: "Model proposal rejected",
11712
+ summary: truncate(data.reason ?? "No reason provided.", 240),
11713
+ severity: "warning"
11714
+ }),
11715
+ failed_target_missing: (data) => ({
11716
+ title: "Model proposal target missing",
11717
+ summary: `The proposed model ${data.target ?? "(unknown model)"} is no longer available; the proposal was closed.`,
11718
+ severity: "error"
11719
+ })
11019
11720
  };
11721
+ function deriveModelProposalEnvelope(event) {
11722
+ const data = asObj(event.data);
11723
+ const build = MODEL_PROPOSAL_ENVELOPES[data.status ?? ""];
11724
+ if (build) return build(data);
11725
+ return {
11726
+ title: `Model proposal update: ${data.target ?? "(unknown model)"}`,
11727
+ summary: `Model proposal status: ${data.status ?? "(unknown)"}.`,
11728
+ severity: "info"
11729
+ };
11730
+ }
11020
11731
  function truncate(s, max) {
11021
11732
  return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
11022
11733
  }
@@ -11063,7 +11774,9 @@ var NOTIFICATION_TOPICS = [
11063
11774
  // Hermes Phase 4 — skill proposal lifecycle.
11064
11775
  "proposal.created",
11065
11776
  "proposal.approved",
11066
- "proposal.rejected"
11777
+ "proposal.rejected",
11778
+ // LMLM Phase 7 — model-proposal lifecycle (colon→dot ⇒ 'local-models.proposal').
11779
+ "local-models:proposal"
11067
11780
  ];
11068
11781
  function newEventId3() {
11069
11782
  return `evt_${randomBytes7(8).toString("hex")}`;
@@ -13156,6 +13869,45 @@ var Orchestrator = class extends EventEmitter {
13156
13869
  * so this map is the single source of truth post-migration.
13157
13870
  */
13158
13871
  localResolvers = /* @__PURE__ */ new Map();
13872
+ /** Phase 4 (D5): pool-state port shared by all local/pi resolvers. Null when LMLM disabled. */
13873
+ poolStateProvider = null;
13874
+ poolStateStore = null;
13875
+ /**
13876
+ * LMLM Phase 6: live model pool + its installer. Constructed only when
13877
+ * `localModels.enabled` and a real `PoolStateStore` exists (not a test
13878
+ * override). Exposed to the server via `getModelPool()`, which retires the
13879
+ * proposals-route 501 stub for `kind: 'model'` approve/reject. Null when LMLM
13880
+ * is disabled. `PoolManager` reads `store.snapshot()` lazily, so constructing
13881
+ * before `store.load()` (in initLocalModelAndPipeline) is safe.
13882
+ */
13883
+ modelPool = null;
13884
+ modelInstaller = null;
13885
+ /**
13886
+ * S1 drain re-entrancy guard (P7-SUG-DRAIN-REENTRANCY). `drainDeferredEvictions`
13887
+ * is fired fire-and-forget from `emitWorkerExit` (and, since P7-SUG-DRAIN-LIVENESS,
13888
+ * piggybacked on each refresh tick). Two overlapping drains would both read the
13889
+ * same `listPendingEvictions()` snapshot, both re-check `isLocalModelInUse`, and
13890
+ * both `await pool.evict` the SAME name — double-calling the installer and
13891
+ * broadcasting a duplicate `evict_completed` frame. The single-threaded event
13892
+ * loop makes a plain boolean sufficient: a drain that arrives while one is
13893
+ * running returns early rather than double-processing.
13894
+ */
13895
+ draining = false;
13896
+ /**
13897
+ * LMLM Phase 6: single per-instance background refresh scheduler. Started in
13898
+ * `initLocalModelAndPipeline` when the pool exists; stopped in `stop()`. Null
13899
+ * when LMLM is disabled. Exposed to the server via `getRefreshScheduler()`.
13900
+ */
13901
+ refreshScheduler = null;
13902
+ /**
13903
+ * LMLM Phase 7: the hardware-aware recommender bound at scheduler start. Reused
13904
+ * by `GET /api/v1/local-models/recommendations`. Null when LMLM is disabled (no
13905
+ * pool → scheduler never armed). Ranks the (currently empty) candidate set —
13906
+ * see the Phase 2 candidate-parser gap noted on `startRefreshScheduler`.
13907
+ */
13908
+ modelRecommender = null;
13909
+ /** Test seam: injected timer/clock for the scheduler so no real 24h timer runs. */
13910
+ schedulerTimerOverride;
13159
13911
  /**
13160
13912
  * Spec B Phase 3: skill catalog (name + cognitiveMode) read once at
13161
13913
  * construction from `projectRoot/agents/skills/`. Consulted by
@@ -13246,6 +13998,7 @@ var Orchestrator = class extends EventEmitter {
13246
13998
  */
13247
13999
  constructor(config, promptTemplate, overrides) {
13248
14000
  super();
14001
+ this.schedulerTimerOverride = overrides?.schedulerTimer ?? null;
13249
14002
  this.setMaxListeners(50);
13250
14003
  this.config = config;
13251
14004
  this.promptTemplate = promptTemplate;
@@ -13293,6 +14046,16 @@ var Orchestrator = class extends EventEmitter {
13293
14046
  this
13294
14047
  );
13295
14048
  this.analysisArchive = new AnalysisArchive(path21.join(config.workspace.root, "..", "analyses"));
14049
+ const localModelsEnabled = this.config.localModels?.enabled === true;
14050
+ if (overrides?.poolState) {
14051
+ this.poolStateProvider = overrides.poolState;
14052
+ } else if (localModelsEnabled) {
14053
+ this.poolStateStore = new PoolStateStore({
14054
+ onWarn: (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0)
14055
+ });
14056
+ this.poolStateProvider = this.poolStateStore;
14057
+ this.initModelPool(this.poolStateStore);
14058
+ }
13296
14059
  const backendsMap = this.config.agent.backends ?? {};
13297
14060
  for (const [name, def] of Object.entries(backendsMap)) {
13298
14061
  if (def.type === "local" || def.type === "pi") {
@@ -13303,6 +14066,7 @@ var Orchestrator = class extends EventEmitter {
13303
14066
  };
13304
14067
  if (def.apiKey !== void 0) resolverOpts.apiKey = def.apiKey;
13305
14068
  if (def.probeIntervalMs !== void 0) resolverOpts.probeIntervalMs = def.probeIntervalMs;
14069
+ if (this.poolStateProvider !== null) resolverOpts.poolState = this.poolStateProvider;
13306
14070
  this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
13307
14071
  }
13308
14072
  }
@@ -13414,7 +14178,26 @@ var Orchestrator = class extends EventEmitter {
13414
14178
  roadmapPath: config.tracker.filePath ?? null,
13415
14179
  dispatchAdHoc: this.dispatchAdHoc.bind(this),
13416
14180
  getLocalModelStatus: () => this.getFirstLocalModelStatus(),
13417
- getLocalModelStatuses: () => this.buildLocalModelStatuses()
14181
+ getLocalModelStatuses: () => this.buildLocalModelStatuses(),
14182
+ // LMLM Phase 6: expose the live pool so kind:'model' approve/reject
14183
+ // reaches PoolManager (retiring the 501). Null when LMLM is disabled.
14184
+ getModelPool: () => this.modelPool,
14185
+ // LMLM Phase 7 / S1: conservative in-use probe so an approved swap/evict
14186
+ // of a model an agent could be using is DEFERRED, not applied mid-request
14187
+ // (ADR 0060). Agent-run-coarse; may over-defer (safe).
14188
+ isModelInUse: (ollamaName) => this.isLocalModelInUse(ollamaName),
14189
+ // LMLM Phase 6: expose the refresh scheduler for POST /local-models/refresh.
14190
+ getRefreshScheduler: () => this.refreshScheduler,
14191
+ // LMLM Phase 7 read surface — hardware / recommendations / model proposals.
14192
+ // Each returns null/[] when LMLM is disabled so the route renders 503/[].
14193
+ getHardwareProfile: () => this.modelPool ? this.detectLmlmHardware() : null,
14194
+ getRecommendations: async ({ top }) => {
14195
+ if (this.modelRecommender === null) return [];
14196
+ const hardware = await this.detectLmlmHardware();
14197
+ const { ranked } = await this.modelRecommender(hardware);
14198
+ return ranked.slice(0, top);
14199
+ },
14200
+ listModelProposals: () => listProposals2(this.projectRoot, { status: "open", kind: "model" })
13418
14201
  });
13419
14202
  this.server.setRecorder(this.recorder);
13420
14203
  this.interactionQueue.onPush((interaction) => {
@@ -13472,6 +14255,60 @@ var Orchestrator = class extends EventEmitter {
13472
14255
  }
13473
14256
  return out;
13474
14257
  }
14258
+ /**
14259
+ * S1 conservative in-use probe (ADR 0060). Returns `true` when ANY agent run
14260
+ * is live AND `ollamaName` is a currently-resolved (or last-detected) local
14261
+ * model — i.e. an agent could be routing inference to it right now.
14262
+ *
14263
+ * This signal is AGENT-RUN-COARSE, not per-request: `state.running` is keyed
14264
+ * by GitHub issue (spawned agent runs), NOT by inference call, and no
14265
+ * per-model request counter exists today. The probe therefore MAY OVER-DEFER
14266
+ * (a swap waits until the pool is idle). Over-deferral is exactly S1's
14267
+ * intended safe failure — never yank a model mid-request; occasionally wait
14268
+ * longer than strictly necessary. A fine-grained per-request signal is an
14269
+ * explicit deferred gap (ADR 0060).
14270
+ */
14271
+ isLocalModelInUse(ollamaName) {
14272
+ if (this.state.running.size === 0) return false;
14273
+ return this.buildLocalModelStatuses().some(
14274
+ (s) => s.resolved === ollamaName || s.detected.includes(ollamaName)
14275
+ );
14276
+ }
14277
+ /**
14278
+ * S1 drain (ADR 0060): complete any eviction that was DEFERRED because its
14279
+ * target was in use, now that the probe reports it idle. Best-effort — called
14280
+ * from the run-completion path; it never blocks dispatch and swallows
14281
+ * per-model errors (a failed or still-busy evict stays pending for the next
14282
+ * drain). `pendingEviction` is a transient overlay, so a missed drain simply
14283
+ * leaves the flag set until the next completion re-checks it.
14284
+ */
14285
+ async drainDeferredEvictions() {
14286
+ const pool = this.modelPool;
14287
+ if (pool === null) return;
14288
+ if (this.draining) return;
14289
+ this.draining = true;
14290
+ try {
14291
+ for (const ollamaName of pool.listPendingEvictions()) {
14292
+ if (this.isLocalModelInUse(ollamaName)) continue;
14293
+ try {
14294
+ const result = await pool.evict({ ollamaName });
14295
+ if (result.status === "error") continue;
14296
+ pool.clearPendingEviction(ollamaName);
14297
+ this.emit("local-models:pool", {
14298
+ action: "evict",
14299
+ // XP-2: `evicted` is uniformly string[] across all local-models:pool
14300
+ // emit sites — the drain wraps its single completed eviction in an
14301
+ // array to match the swap/add multi-evict shape.
14302
+ evicted: [ollamaName],
14303
+ phase: "evict_completed"
14304
+ });
14305
+ } catch {
14306
+ }
14307
+ }
14308
+ } finally {
14309
+ this.draining = false;
14310
+ }
14311
+ }
13475
14312
  createTracker() {
13476
14313
  if (this.config.tracker.kind === "github-issues") {
13477
14314
  const trackerCfg = {
@@ -14318,6 +15155,7 @@ ${messages}`);
14318
15155
  error,
14319
15156
  (effect) => this.handleEffect(effect)
14320
15157
  );
15158
+ void this.drainDeferredEvictions();
14321
15159
  this.emit("state_change", this.getSnapshot());
14322
15160
  }
14323
15161
  /**
@@ -14405,6 +15243,120 @@ ${messages}`);
14405
15243
  * before constructing the intelligence pipeline. Subscribes the dashboard
14406
15244
  * broadcast stub to status changes. Called exactly once from start().
14407
15245
  */
15246
+ /**
15247
+ * LMLM Phase 6: construct the live `PoolManager` (Ollama installer + the
15248
+ * loaded pool-state store) and stash it for `getModelPool()`. Defensive
15249
+ * config fallbacks: `ollamaEndpoint → http://localhost:11434`. The pool reads
15250
+ * `store.snapshot()` lazily, so this runs safely at construction time before
15251
+ * `store.load()`.
15252
+ */
15253
+ initModelPool(store) {
15254
+ const onWarn = (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0);
15255
+ const installerCfg = this.config.localModels?.installer;
15256
+ this.modelInstaller = new OllamaInstallAdapter({
15257
+ baseUrl: installerCfg?.ollamaEndpoint ?? "http://localhost:11434",
15258
+ onWarn
15259
+ });
15260
+ this.modelPool = new PoolManager({ store, installer: this.modelInstaller, onWarn });
15261
+ }
15262
+ /**
15263
+ * LMLM Phase 7 wiring: apply the operator's configured pool bounds (disk
15264
+ * budget + org/family allowlist) from `localModels.pool` to the live pool.
15265
+ * Called after `PoolStateStore.load()` so config wins over persisted bounds
15266
+ * (D2, declarative precedence). No-op when the pool is null (LMLM disabled)
15267
+ * or no `pool` block is configured, so it is safe to call unconditionally
15268
+ * on the startup path.
15269
+ */
15270
+ async applyConfiguredPoolBounds() {
15271
+ const pool = this.modelPool;
15272
+ const bounds = this.config.localModels?.pool;
15273
+ if (pool === null || !bounds) return;
15274
+ await pool.configurePool({
15275
+ diskBudgetGb: bounds.diskBudgetGb,
15276
+ allowedOrgs: bounds.allowedOrgs,
15277
+ allowedFamilies: bounds.allowedFamilies
15278
+ });
15279
+ }
15280
+ /**
15281
+ * LMLM Phase 6: arm the single background refresh scheduler over the live
15282
+ * pool. No-op when LMLM is disabled (`modelPool` null). Each tick runs
15283
+ * hardware→recommend→reconcile(D12 drift)→diff→emit→score-writeback.
15284
+ *
15285
+ * NOTE (deferred): the recommender is seeded with an empty candidate set —
15286
+ * Phase 2's live-HF→RankerCandidate parser was never built, so autonomous
15287
+ * swap-proposal discovery is out of scope here (flagged concern). The tick
15288
+ * still performs F10 drift reconciliation, O1 logging, and re-ranks/dedups
15289
+ * whatever candidates are supplied — the wiring is complete and candidate
15290
+ * breadth is the only piece deferred to the Phase 2 recommender.
15291
+ */
15292
+ startRefreshScheduler() {
15293
+ if (this.modelPool === null) return;
15294
+ const pool = this.modelPool;
15295
+ const refreshCfg = this.config.localModels?.refresh;
15296
+ const frozen = loadFrozenCandidates();
15297
+ const candidates = selectCandidates2(frozen.candidates, this.config.localModels?.pool);
15298
+ for (const warning of frozen.warnings) {
15299
+ this.logger.warn("LMLM frozen candidate snapshot degraded", { warning });
15300
+ }
15301
+ if (candidates.length === 0) {
15302
+ this.logger.warn("LMLM recommender has no candidates", {
15303
+ frozenCount: frozen.candidates.length,
15304
+ allowedOrgs: this.config.localModels?.pool?.allowedOrgs ?? []
15305
+ });
15306
+ }
15307
+ const recommend = createNativeRecommender({ candidates });
15308
+ this.modelRecommender = recommend;
15309
+ this.refreshScheduler = new RefreshScheduler({
15310
+ runTick: () => runRefreshTick({
15311
+ detectHardware: () => this.detectLmlmHardware(),
15312
+ recommend,
15313
+ poolManager: pool,
15314
+ dedupSource: () => this.lmlmDedupSource(),
15315
+ // Phase 7: after persisting the proposal, emit `local-models:proposal`
15316
+ // (== MODEL_PROPOSAL_TOPIC) on the bus so it fans out to WS clients and
15317
+ // notification sinks. Literal to avoid a proposals/model-handlers cycle.
15318
+ emitProposal: (c) => createModelProposal2(this.projectRoot, c).then((record) => {
15319
+ this.emit("local-models:proposal", {
15320
+ id: record.id,
15321
+ status: "created",
15322
+ action: c.action,
15323
+ target: c.target.ollamaName
15324
+ });
15325
+ }),
15326
+ proposalThreshold: refreshCfg?.proposalThreshold ?? 5
15327
+ }).then((result) => {
15328
+ void this.drainDeferredEvictions();
15329
+ return result;
15330
+ }),
15331
+ intervalMs: refreshCfg?.intervalMs ?? 864e5,
15332
+ jitterMs: refreshCfg?.jitterMs ?? 6e5,
15333
+ logger: this.logger,
15334
+ ...this.schedulerTimerOverride ?? {}
15335
+ });
15336
+ this.refreshScheduler.start();
15337
+ }
15338
+ /** Resolve the hardware profile for a refresh tick (operator override wins). */
15339
+ async detectLmlmHardware() {
15340
+ const override = this.config.localModels?.hardware?.override;
15341
+ const detector = new HardwareDetector(override !== void 0 ? { override } : {});
15342
+ return (await detector.detect()).profile;
15343
+ }
15344
+ /** Map the on-disk model-proposal queue to F7 dedup pairs (open→pending, rejected→rejected). */
15345
+ async lmlmDedupSource() {
15346
+ const proposals = await listProposals2(this.projectRoot, { kind: "model" });
15347
+ const pending = [];
15348
+ const rejected = [];
15349
+ for (const p of proposals) {
15350
+ if (p.kind !== "model") continue;
15351
+ const pair = {
15352
+ target: p.model.target.ollamaName,
15353
+ ...p.model.replaces ? { replaces: p.model.replaces.ollamaName } : {}
15354
+ };
15355
+ if (p.status === "open") pending.push(pair);
15356
+ else if (p.status === "rejected") rejected.push(pair);
15357
+ }
15358
+ return { pending, rejected };
15359
+ }
14408
15360
  async initLocalModelAndPipeline() {
14409
15361
  if (this.localResolvers.size > 0) {
14410
15362
  const backends = this.config.agent.backends ?? {};
@@ -14427,10 +15379,15 @@ ${messages}`);
14427
15379
  });
14428
15380
  this.localModelStatusUnsubscribes.push(unsubscribe);
14429
15381
  }
15382
+ if (this.poolStateStore !== null) {
15383
+ await this.poolStateStore.load();
15384
+ await this.applyConfiguredPoolBounds();
15385
+ }
14430
15386
  for (const resolver of this.localResolvers.values()) {
14431
15387
  await resolver.start();
14432
15388
  }
14433
15389
  }
15390
+ this.startRefreshScheduler();
14434
15391
  this.pipeline = this.createIntelligencePipeline();
14435
15392
  this.server?.setPipeline(this.pipeline);
14436
15393
  }
@@ -14506,6 +15463,8 @@ ${messages}`);
14506
15463
  for (const resolver of this.localResolvers.values()) {
14507
15464
  resolver.stop();
14508
15465
  }
15466
+ this.refreshScheduler?.stop();
15467
+ this.refreshScheduler = null;
14509
15468
  if (this.maintenanceScheduler) {
14510
15469
  this.maintenanceScheduler.stop();
14511
15470
  this.maintenanceScheduler = null;