@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.js CHANGED
@@ -3109,7 +3109,7 @@ var PromptRenderer = class {
3109
3109
  var import_node_events = require("events");
3110
3110
  var path21 = __toESM(require("path"));
3111
3111
  var import_node_crypto15 = require("crypto");
3112
- var import_core15 = require("@harness-engineering/core");
3112
+ var import_core16 = require("@harness-engineering/core");
3113
3113
 
3114
3114
  // src/core/stall-detector.ts
3115
3115
  function detectStalledIssues(running, nowMs, stallTimeoutMs) {
@@ -3685,7 +3685,7 @@ var CompletionHandler = class {
3685
3685
  };
3686
3686
 
3687
3687
  // src/orchestrator.ts
3688
- var import_core16 = require("@harness-engineering/core");
3688
+ var import_core17 = require("@harness-engineering/core");
3689
3689
 
3690
3690
  // src/tracker/adapters/github-issues-issue-tracker.ts
3691
3691
  var import_types9 = require("@harness-engineering/types");
@@ -3881,6 +3881,7 @@ var AgentRunner = class {
3881
3881
  };
3882
3882
 
3883
3883
  // src/agent/local-model-resolver.ts
3884
+ var import_local_models = require("@harness-engineering/local-models");
3884
3885
  var DEFAULT_PROBE_INTERVAL_MS = 3e4;
3885
3886
  var MIN_PROBE_INTERVAL_MS = 1e3;
3886
3887
  var DEFAULT_API_KEY = "lm-studio";
@@ -3937,6 +3938,7 @@ var LocalModelResolver = class {
3937
3938
  endpoint;
3938
3939
  apiKey;
3939
3940
  configured;
3941
+ poolState;
3940
3942
  probeIntervalMs;
3941
3943
  fetchModels;
3942
3944
  logger;
@@ -3965,6 +3967,9 @@ var LocalModelResolver = class {
3965
3967
  this.apiKey = opts.apiKey;
3966
3968
  }
3967
3969
  this.configured = [...opts.configured];
3970
+ if (opts.poolState !== void 0) {
3971
+ this.poolState = opts.poolState;
3972
+ }
3968
3973
  const interval = opts.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
3969
3974
  this.probeIntervalMs = Math.max(MIN_PROBE_INTERVAL_MS, interval);
3970
3975
  const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
@@ -3978,13 +3983,21 @@ var LocalModelResolver = class {
3978
3983
  return {
3979
3984
  available: this.available,
3980
3985
  resolved: this.resolved,
3981
- configured: [...this.configured],
3986
+ configured: this.candidates(),
3982
3987
  detected: [...this.detected],
3983
3988
  lastProbeAt: this.lastProbeAt,
3984
3989
  lastError: this.lastError,
3985
3990
  warnings: [...this.warnings]
3986
3991
  };
3987
3992
  }
3993
+ /**
3994
+ * Effective candidate list. With a poolState port present the list derives
3995
+ * from pool entries (currentScore desc → ollamaName); otherwise the static
3996
+ * `configured` list is returned unchanged (byte-identical to pre-Phase-4).
3997
+ */
3998
+ candidates() {
3999
+ return this.poolState ? (0, import_local_models.poolStateToCandidates)(this.poolState.snapshot()) : [...this.configured];
4000
+ }
3988
4001
  onStatusChange(handler) {
3989
4002
  this.listeners.add(handler);
3990
4003
  return () => {
@@ -4008,11 +4021,12 @@ var LocalModelResolver = class {
4008
4021
  this.detected = [...detected];
4009
4022
  this.lastError = null;
4010
4023
  this.lastProbeAt = (/* @__PURE__ */ new Date()).toISOString();
4011
- const match = this.configured.find((id) => detected.includes(id)) ?? null;
4024
+ const candidates = this.candidates();
4025
+ const match = candidates.find((id) => detected.includes(id)) ?? null;
4012
4026
  this.resolved = match;
4013
4027
  this.available = match !== null;
4014
4028
  this.warnings = match ? [] : [
4015
- `No configured local model is loaded. Configured: [${this.configured.join(", ")}]. Detected: [${detected.join(", ")}].`
4029
+ `No configured local model is loaded. Configured: [${candidates.join(", ")}]. Detected: [${detected.join(", ")}].`
4016
4030
  ];
4017
4031
  } catch (err) {
4018
4032
  const message = err instanceof Error ? err.message : "probe failed";
@@ -4061,7 +4075,7 @@ var LocalModelResolver = class {
4061
4075
  return JSON.stringify({
4062
4076
  available: this.available,
4063
4077
  resolved: this.resolved,
4064
- configured: this.configured,
4078
+ configured: this.candidates(),
4065
4079
  detected: this.detected,
4066
4080
  lastError: this.lastError,
4067
4081
  warnings: this.warnings
@@ -4069,6 +4083,10 @@ var LocalModelResolver = class {
4069
4083
  }
4070
4084
  };
4071
4085
 
4086
+ // src/orchestrator.ts
4087
+ var import_local_models4 = require("@harness-engineering/local-models");
4088
+ var import_core18 = require("@harness-engineering/core");
4089
+
4072
4090
  // src/agent/config-migration.ts
4073
4091
  var MIGRATION_GUIDE = "docs/guides/multi-backend-routing.md";
4074
4092
  var CASE1_ALWAYS_SUPPRESS = /* @__PURE__ */ new Set(["agent.backend"]);
@@ -7031,7 +7049,7 @@ function buildRoutingUseCase(issue, backendParam, catalog) {
7031
7049
  // src/server/http.ts
7032
7050
  var http = __toESM(require("http"));
7033
7051
  var path17 = __toESM(require("path"));
7034
- var import_core11 = require("@harness-engineering/core");
7052
+ var import_core12 = require("@harness-engineering/core");
7035
7053
 
7036
7054
  // src/server/websocket.ts
7037
7055
  var import_ws = require("ws");
@@ -8395,10 +8413,10 @@ function checkDiff(diff) {
8395
8413
  function deriveFindings(proposal) {
8396
8414
  const findings = [];
8397
8415
  findings.push(...checkName(proposal.content.name));
8398
- if (proposal.kind === "new-skill") {
8416
+ if (proposal.skillKind === "new-skill") {
8399
8417
  findings.push(...checkSkillYaml(proposal.content.skillYaml ?? ""));
8400
8418
  findings.push(...checkSkillMd(proposal.content.skillMd ?? ""));
8401
- } else if (proposal.kind === "refinement") {
8419
+ } else if (proposal.skillKind === "refinement") {
8402
8420
  findings.push(...checkDiff(proposal.content.diff ?? ""));
8403
8421
  }
8404
8422
  return findings;
@@ -8406,6 +8424,9 @@ function deriveFindings(proposal) {
8406
8424
  async function runGate(projectPath, proposalId) {
8407
8425
  const proposal = await (0, import_core8.getProposal)(projectPath, proposalId);
8408
8426
  if (!proposal) throw new import_core8.ProposalNotFoundError(proposalId);
8427
+ if (proposal.kind !== "skill") {
8428
+ throw new GateRunError(`gate applies to skill proposals only (got ${proposal.kind})`);
8429
+ }
8409
8430
  if (proposal.status === "approved" || proposal.status === "rejected") {
8410
8431
  throw new GateRunError(
8411
8432
  `proposal ${proposalId} is already ${proposal.status}; cannot re-run the gate`
@@ -8531,8 +8552,11 @@ async function promoteRefinement(projectPath, proposal) {
8531
8552
  async function promote(projectPath, proposalId, decidedBy) {
8532
8553
  const proposal = await (0, import_core9.getProposal)(projectPath, proposalId);
8533
8554
  if (!proposal) throw new import_core9.ProposalNotFoundError(proposalId);
8555
+ if (proposal.kind !== "skill") {
8556
+ throw new PromotionError("only skill proposals are promotable to the catalog");
8557
+ }
8534
8558
  assertGateReady(proposal);
8535
- const out = proposal.kind === "new-skill" ? await promoteNewSkill(projectPath, proposal) : await promoteRefinement(projectPath, proposal);
8559
+ const out = proposal.skillKind === "new-skill" ? await promoteNewSkill(projectPath, proposal) : await promoteRefinement(projectPath, proposal);
8536
8560
  await (0, import_core9.updateProposal)(projectPath, proposalId, {
8537
8561
  status: "approved",
8538
8562
  decision: {
@@ -8555,7 +8579,7 @@ function emit3(bus, topic, data) {
8555
8579
  function emitProposalCreated(bus, proposal) {
8556
8580
  const data = {
8557
8581
  id: proposal.id,
8558
- kind: proposal.kind,
8582
+ kind: proposal.skillKind,
8559
8583
  name: proposal.content.name,
8560
8584
  proposedBy: proposal.proposedBy,
8561
8585
  justification: proposal.source.justification
@@ -8566,7 +8590,7 @@ function emitProposalCreated(bus, proposal) {
8566
8590
  function emitProposalApproved(bus, proposal) {
8567
8591
  const data = {
8568
8592
  id: proposal.id,
8569
- kind: proposal.kind,
8593
+ kind: proposal.skillKind,
8570
8594
  name: proposal.content.name,
8571
8595
  decidedBy: proposal.decision?.decidedBy ?? "(unknown)"
8572
8596
  };
@@ -8576,7 +8600,7 @@ function emitProposalApproved(bus, proposal) {
8576
8600
  function emitProposalRejected(bus, proposal) {
8577
8601
  const data = {
8578
8602
  id: proposal.id,
8579
- kind: proposal.kind,
8603
+ kind: proposal.skillKind,
8580
8604
  name: proposal.content.name,
8581
8605
  decidedBy: proposal.decision?.decidedBy ?? "(unknown)",
8582
8606
  reason: proposal.decision?.reason ?? "(no reason given)"
@@ -8584,6 +8608,163 @@ function emitProposalRejected(bus, proposal) {
8584
8608
  emit3(bus, "proposal.rejected", data);
8585
8609
  }
8586
8610
 
8611
+ // src/proposals/model-handlers.ts
8612
+ function isInUse(deps, ollamaName) {
8613
+ return deps.isModelInUse ? deps.isModelInUse(ollamaName) : false;
8614
+ }
8615
+ var MODEL_PROPOSAL_TOPIC = "local-models:proposal";
8616
+ var MODEL_POOL_TOPIC = "local-models:pool";
8617
+ function decisionOf(deps, action, reason) {
8618
+ return {
8619
+ decidedAt: (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
8620
+ decidedBy: deps.decidedBy ?? "orchestrator",
8621
+ action,
8622
+ ...reason !== void 0 ? { reason } : {}
8623
+ };
8624
+ }
8625
+ function emitApproved(deps, proposal) {
8626
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8627
+ id: proposal.id,
8628
+ status: "approved",
8629
+ action: proposal.model.action,
8630
+ target: proposal.model.target.ollamaName
8631
+ });
8632
+ }
8633
+ async function deferEviction(deps, proposal, deferredName, event, evicted) {
8634
+ deps.pool.markPendingEviction(deferredName);
8635
+ const updated = await deps.updateProposal(proposal.id, {
8636
+ status: "approved",
8637
+ decision: decisionOf(deps, "approved")
8638
+ });
8639
+ deps.bus.emit(MODEL_POOL_TOPIC, event);
8640
+ emitApproved(deps, proposal);
8641
+ return { status: "approved", proposal: updated, evicted };
8642
+ }
8643
+ async function onApproveModelProposal(deps, proposal) {
8644
+ const { model } = proposal;
8645
+ if (model.action === "evict") {
8646
+ return applyEvictOnly(deps, proposal);
8647
+ }
8648
+ const installResult = await deps.pool.install({
8649
+ hfRepoId: model.target.hfRepoId,
8650
+ ollamaName: model.target.ollamaName,
8651
+ ...model.replaces !== void 0 ? { replaces: model.replaces.ollamaName } : {},
8652
+ ...model.diskImpactGb > 0 ? { sizeOnDiskGb: model.diskImpactGb } : {}
8653
+ });
8654
+ if (installResult.status === "error") {
8655
+ if (installResult.code === "failed_target_missing") {
8656
+ const updated2 = await deps.updateProposal(proposal.id, {
8657
+ status: "failed_target_missing"
8658
+ });
8659
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8660
+ id: proposal.id,
8661
+ status: "failed_target_missing",
8662
+ action: model.action,
8663
+ target: model.target.ollamaName
8664
+ });
8665
+ return { status: "failed_target_missing", proposal: updated2 };
8666
+ }
8667
+ return { status: "error", code: installResult.code, message: installResult.message };
8668
+ }
8669
+ const evicted = [...installResult.evicted];
8670
+ if (model.action === "swap" && model.replaces !== void 0) {
8671
+ const replacesName = model.replaces.ollamaName;
8672
+ if (isInUse(deps, replacesName)) {
8673
+ return deferEviction(
8674
+ deps,
8675
+ proposal,
8676
+ replacesName,
8677
+ {
8678
+ id: proposal.id,
8679
+ action: model.action,
8680
+ installed: model.target.ollamaName,
8681
+ phase: "evict_deferred",
8682
+ deferred: replacesName,
8683
+ ...installResult.evicted.length > 0 ? { evicted: installResult.evicted.map((e) => e.ollamaName) } : {}
8684
+ },
8685
+ evicted
8686
+ );
8687
+ }
8688
+ const evictResult = await deps.pool.evict({ ollamaName: replacesName });
8689
+ if (evictResult.status === "error") {
8690
+ deps.bus.emit(MODEL_POOL_TOPIC, {
8691
+ id: proposal.id,
8692
+ action: model.action,
8693
+ installed: model.target.ollamaName,
8694
+ phase: "swap_evict_failed"
8695
+ });
8696
+ return { status: "error", code: evictResult.code, message: evictResult.message };
8697
+ }
8698
+ if (evictResult.removed !== null) {
8699
+ evicted.push(evictResult.removed);
8700
+ }
8701
+ }
8702
+ const updated = await deps.updateProposal(proposal.id, {
8703
+ status: "approved",
8704
+ decision: decisionOf(deps, "approved")
8705
+ });
8706
+ const evictedNames = [
8707
+ ...installResult.evicted.map((e) => e.ollamaName),
8708
+ ...model.replaces !== void 0 ? [model.replaces.ollamaName] : []
8709
+ ];
8710
+ deps.bus.emit(MODEL_POOL_TOPIC, {
8711
+ id: proposal.id,
8712
+ action: model.action,
8713
+ installed: model.target.ollamaName,
8714
+ ...evictedNames.length > 0 ? { evicted: evictedNames } : {}
8715
+ });
8716
+ emitApproved(deps, proposal);
8717
+ return { status: "approved", proposal: updated, evicted };
8718
+ }
8719
+ async function applyEvictOnly(deps, proposal) {
8720
+ const targetName = proposal.model.target.ollamaName;
8721
+ if (isInUse(deps, targetName)) {
8722
+ return deferEviction(
8723
+ deps,
8724
+ proposal,
8725
+ targetName,
8726
+ { id: proposal.id, action: "evict", phase: "evict_deferred", deferred: targetName },
8727
+ []
8728
+ );
8729
+ }
8730
+ const evictResult = await deps.pool.evict({ ollamaName: targetName });
8731
+ if (evictResult.status === "error") {
8732
+ return { status: "error", code: evictResult.code, message: evictResult.message };
8733
+ }
8734
+ const updated = await deps.updateProposal(proposal.id, {
8735
+ status: "approved",
8736
+ decision: decisionOf(deps, "approved")
8737
+ });
8738
+ deps.bus.emit(MODEL_POOL_TOPIC, {
8739
+ id: proposal.id,
8740
+ action: "evict",
8741
+ // XP-2: `evicted` is string[] at EVERY local-models:pool emit site (swap/add
8742
+ // list multiple removals) — the evict-only path wraps its single removal in
8743
+ // an array too, so consumers never branch on string-vs-array.
8744
+ evicted: [proposal.model.target.ollamaName]
8745
+ });
8746
+ emitApproved(deps, proposal);
8747
+ return {
8748
+ status: "approved",
8749
+ proposal: updated,
8750
+ evicted: evictResult.removed !== null && evictResult.removed !== void 0 ? [evictResult.removed] : []
8751
+ };
8752
+ }
8753
+ async function onRejectModelProposal(deps, proposal, reason) {
8754
+ const updated = await deps.updateProposal(proposal.id, {
8755
+ status: "rejected",
8756
+ decision: decisionOf(deps, "rejected", reason)
8757
+ });
8758
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8759
+ id: proposal.id,
8760
+ status: "rejected",
8761
+ target: proposal.model.target.ollamaName,
8762
+ replaces: proposal.model.replaces?.ollamaName,
8763
+ reason
8764
+ });
8765
+ return updated;
8766
+ }
8767
+
8587
8768
  // src/server/routes/v1/proposals.ts
8588
8769
  var LIST_RE = /^\/api\/v1\/proposals(?:\?.*)?$/;
8589
8770
  var SINGLE_RE = /^\/api\/v1\/proposals\/([^/?]+)(?:\?.*)?$/;
@@ -8609,6 +8790,20 @@ function getDecidedBy(req, deps) {
8609
8790
  const token = req._authToken;
8610
8791
  return token?.id ?? "unknown";
8611
8792
  }
8793
+ function modelHandlerDeps(deps, req) {
8794
+ return {
8795
+ pool: deps.modelPool,
8796
+ bus: deps.bus,
8797
+ // Core's `updateProposal` patch type intersects the skill+model status
8798
+ // enums, which statically excludes model-only statuses like
8799
+ // `failed_target_missing`. The runtime parses through `ProposalSchema` (the
8800
+ // model variant accepts that status), so the cast is sound — only the
8801
+ // intersection type is too narrow.
8802
+ updateProposal: (id, patch) => (0, import_core10.updateProposal)(deps.projectPath, id, patch),
8803
+ decidedBy: getDecidedBy(req, deps),
8804
+ ...deps.isModelInUse ? { isModelInUse: deps.isModelInUse } : {}
8805
+ };
8806
+ }
8612
8807
  function parseStatusFromQuery(url) {
8613
8808
  const queryIdx = url.indexOf("?");
8614
8809
  if (queryIdx === -1) return void 0;
@@ -8653,11 +8848,31 @@ async function handleRunGate(res, deps, id) {
8653
8848
  }
8654
8849
  }
8655
8850
  async function handleApprove(req, res, deps, id) {
8851
+ const existing = await (0, import_core10.getProposal)(deps.projectPath, id);
8852
+ if (!existing) {
8853
+ sendJSON8(res, 404, { error: "Proposal not found" });
8854
+ return;
8855
+ }
8856
+ if (existing.kind === "model") {
8857
+ if (!deps.modelPool) {
8858
+ sendJSON8(res, 501, { error: "model proposal handlers not configured" });
8859
+ return;
8860
+ }
8861
+ if (existing.status === "approved" || existing.status === "rejected" || existing.status === "failed_target_missing") {
8862
+ sendJSON8(res, 409, {
8863
+ error: `proposal already ${existing.status}; cannot approve`
8864
+ });
8865
+ return;
8866
+ }
8867
+ const outcome = await onApproveModelProposal(modelHandlerDeps(deps, req), existing);
8868
+ sendJSON8(res, outcome.status === "error" ? 422 : 200, outcome);
8869
+ return;
8870
+ }
8656
8871
  const decidedBy = getDecidedBy(req, deps);
8657
8872
  try {
8658
8873
  const result = await promote(deps.projectPath, id, decidedBy);
8659
8874
  const proposal = await (0, import_core10.getProposal)(deps.projectPath, id);
8660
- if (proposal) emitProposalApproved(deps.bus, proposal);
8875
+ if (proposal && proposal.kind === "skill") emitProposalApproved(deps.bus, proposal);
8661
8876
  sendJSON8(res, 200, { promotion: result, proposal });
8662
8877
  } catch (err) {
8663
8878
  if (err instanceof import_core10.ProposalNotFoundError) {
@@ -8709,6 +8924,19 @@ async function handleReject(req, res, deps, id) {
8709
8924
  });
8710
8925
  return;
8711
8926
  }
8927
+ if (proposal.kind === "model") {
8928
+ if (!deps.modelPool) {
8929
+ sendJSON8(res, 501, { error: "model proposal handlers not configured" });
8930
+ return;
8931
+ }
8932
+ const updated2 = await onRejectModelProposal(
8933
+ modelHandlerDeps(deps, req),
8934
+ proposal,
8935
+ parsed.data.reason
8936
+ );
8937
+ sendJSON8(res, 200, updated2);
8938
+ return;
8939
+ }
8712
8940
  const decidedBy = getDecidedBy(req, deps);
8713
8941
  const updated = await (0, import_core10.updateProposal)(deps.projectPath, id, {
8714
8942
  status: "rejected",
@@ -8719,7 +8947,7 @@ async function handleReject(req, res, deps, id) {
8719
8947
  reason: parsed.data.reason
8720
8948
  }
8721
8949
  });
8722
- emitProposalRejected(deps.bus, updated);
8950
+ if (updated.kind === "skill") emitProposalRejected(deps.bus, updated);
8723
8951
  sendJSON8(res, 200, updated);
8724
8952
  }
8725
8953
  async function handleEdit(req, res, deps, id) {
@@ -8747,6 +8975,10 @@ async function handleEdit(req, res, deps, id) {
8747
8975
  sendJSON8(res, 404, { error: "Proposal not found" });
8748
8976
  return;
8749
8977
  }
8978
+ if (existing.kind !== "skill") {
8979
+ sendJSON8(res, 422, { error: "edit applies to skill proposals only" });
8980
+ return;
8981
+ }
8750
8982
  if (existing.status === "approved" || existing.status === "rejected") {
8751
8983
  sendJSON8(res, 409, {
8752
8984
  error: `proposal already ${existing.status}; cannot edit`
@@ -8812,17 +9044,329 @@ function handleV1ProposalsRoute(req, res, deps) {
8812
9044
  return false;
8813
9045
  }
8814
9046
 
9047
+ // src/server/routes/v1/local-models.ts
9048
+ var import_local_models2 = require("@harness-engineering/local-models");
9049
+ var REFRESH_RE = /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/;
9050
+ var HARDWARE_RE = /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/;
9051
+ var POOL_RE = /^\/api\/v1\/local-models\/pool(?:\?.*)?$/;
9052
+ var RECS_RE = /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/;
9053
+ var PROPOSALS_RE = /^\/api\/v1\/local-models\/proposals(?:\?.*)?$/;
9054
+ var RECOMMENDATION_PROFILES = ["general", "coding", "reasoning"];
9055
+ function sendJSON9(res, status, body) {
9056
+ res.writeHead(status, { "Content-Type": "application/json" });
9057
+ res.end(JSON.stringify(body));
9058
+ }
9059
+ function handleV1LocalModelsRoute(req, res, deps) {
9060
+ const url = req.url ?? "";
9061
+ const method = req.method ?? "GET";
9062
+ if (method === "GET") {
9063
+ if (HARDWARE_RE.test(url)) {
9064
+ void runGet(res, () => handleHardware(res, deps));
9065
+ return true;
9066
+ }
9067
+ if (POOL_RE.test(url)) {
9068
+ void runGet(res, () => handlePool(res, deps));
9069
+ return true;
9070
+ }
9071
+ if (RECS_RE.test(url)) {
9072
+ void runGet(res, () => handleRecommendations(res, url, deps));
9073
+ return true;
9074
+ }
9075
+ if (PROPOSALS_RE.test(url)) {
9076
+ void runGet(res, () => handleProposals(res, deps));
9077
+ return true;
9078
+ }
9079
+ return false;
9080
+ }
9081
+ if (method !== "POST" || !REFRESH_RE.test(url)) return false;
9082
+ const scheduler = deps.getRefreshScheduler();
9083
+ if (scheduler === null) {
9084
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9085
+ return true;
9086
+ }
9087
+ void runForceRefresh(res, scheduler, deps);
9088
+ return true;
9089
+ }
9090
+ async function runGet(res, handler) {
9091
+ try {
9092
+ await handler();
9093
+ } catch (err) {
9094
+ sendJSON9(res, 500, {
9095
+ error: "local-models route failed",
9096
+ detail: err instanceof Error ? err.message : "unknown"
9097
+ });
9098
+ }
9099
+ }
9100
+ async function handleHardware(res, deps) {
9101
+ const pending = deps.getHardwareProfile?.() ?? null;
9102
+ if (pending === null) {
9103
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9104
+ return;
9105
+ }
9106
+ sendJSON9(res, 200, await pending);
9107
+ }
9108
+ async function handlePool(res, deps) {
9109
+ const pool = deps.getModelPool?.() ?? null;
9110
+ if (pool === null) {
9111
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9112
+ return;
9113
+ }
9114
+ sendJSON9(res, 200, pool.viewState?.() ?? pool.snapshot());
9115
+ }
9116
+ var MAX_RECOMMENDATION_TOP = 100;
9117
+ async function handleRecommendations(res, url, deps) {
9118
+ if (deps.getRecommendations === void 0) {
9119
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9120
+ return;
9121
+ }
9122
+ const params = new URLSearchParams(url.split("?")[1] ?? "");
9123
+ const topRaw = params.get("top");
9124
+ let top = 10;
9125
+ if (topRaw !== null) {
9126
+ const n = Number(topRaw);
9127
+ if (!Number.isInteger(n) || n <= 0) {
9128
+ sendJSON9(res, 400, { error: "invalid top: expected a positive integer" });
9129
+ return;
9130
+ }
9131
+ if (n > MAX_RECOMMENDATION_TOP) {
9132
+ sendJSON9(res, 400, { error: `invalid top: exceeds maximum of ${MAX_RECOMMENDATION_TOP}` });
9133
+ return;
9134
+ }
9135
+ top = n;
9136
+ }
9137
+ const profileRaw = params.get("profile");
9138
+ let profile = "general";
9139
+ if (profileRaw !== null) {
9140
+ if (!isRecommendationProfile(profileRaw)) {
9141
+ sendJSON9(res, 400, {
9142
+ error: `invalid profile: expected one of ${RECOMMENDATION_PROFILES.join(", ")}`
9143
+ });
9144
+ return;
9145
+ }
9146
+ profile = profileRaw;
9147
+ }
9148
+ sendJSON9(res, 200, await deps.getRecommendations({ top, profile }));
9149
+ }
9150
+ async function handleProposals(res, deps) {
9151
+ if (deps.listModelProposals === void 0) {
9152
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9153
+ return;
9154
+ }
9155
+ sendJSON9(res, 200, await deps.listModelProposals());
9156
+ }
9157
+ function isRecommendationProfile(value) {
9158
+ return RECOMMENDATION_PROFILES.includes(value);
9159
+ }
9160
+ async function runForceRefresh(res, scheduler, deps) {
9161
+ let result;
9162
+ try {
9163
+ result = await scheduler.forceRefresh();
9164
+ } catch (err) {
9165
+ sendJSON9(res, 500, {
9166
+ error: "refresh tick failed",
9167
+ detail: err instanceof Error ? err.message : "unknown"
9168
+ });
9169
+ return;
9170
+ }
9171
+ if ((0, import_local_models2.isTickHardFailure)(result)) {
9172
+ sendJSON9(res, 503, {
9173
+ error: "refresh hard failure: HuggingFace unreachable and no benchmark snapshot loaded",
9174
+ emitted: result.proposalsEmitted,
9175
+ warnings: result.warnings,
9176
+ errors: result.errors
9177
+ });
9178
+ return;
9179
+ }
9180
+ const proposals = deps.listModelProposals ? await deps.listModelProposals() : [];
9181
+ sendJSON9(res, 200, {
9182
+ emitted: result.proposalsEmitted,
9183
+ reconciledRemoved: result.reconciledRemoved,
9184
+ proposals,
9185
+ warnings: result.warnings
9186
+ });
9187
+ }
9188
+
9189
+ // src/server/routes/v1/local-models-pool-mutation.ts
9190
+ var import_core11 = require("@harness-engineering/core");
9191
+ var INSTALL_RE = /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/;
9192
+ var REMOVE_RE = /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/;
9193
+ var RESOLVE_TOP = 50;
9194
+ function sendJSON10(res, status, body) {
9195
+ res.writeHead(status, { "Content-Type": "application/json" });
9196
+ res.end(JSON.stringify(body));
9197
+ }
9198
+ function decidedByOf(deps, req) {
9199
+ if (deps.getDecidedBy) return deps.getDecidedBy(req);
9200
+ const token = req._authToken;
9201
+ return token?.id ?? "operator";
9202
+ }
9203
+ function handlerDeps(deps, req, pool) {
9204
+ return {
9205
+ pool,
9206
+ bus: deps.bus,
9207
+ updateProposal: (id, patch) => (0, import_core11.updateProposal)(deps.projectPath, id, patch),
9208
+ decidedBy: decidedByOf(deps, req),
9209
+ ...deps.isModelInUse ? { isModelInUse: deps.isModelInUse } : {}
9210
+ };
9211
+ }
9212
+ async function readJsonBody(req) {
9213
+ try {
9214
+ const raw = await readBody(req);
9215
+ if (raw.length === 0) return {};
9216
+ const parsed = JSON.parse(raw);
9217
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
9218
+ } catch {
9219
+ return void 0;
9220
+ }
9221
+ }
9222
+ async function handleInstall(req, res, deps) {
9223
+ const pool = deps.getModelPool();
9224
+ if (pool === null) return sendJSON10(res, 503, { error: "LMLM disabled" });
9225
+ const body = await readJsonBody(req);
9226
+ if (body === void 0) return sendJSON10(res, 400, { error: "invalid JSON body" });
9227
+ const hfRepoId = typeof body.hfRepoId === "string" ? body.hfRepoId : "";
9228
+ if (hfRepoId.length === 0) return sendJSON10(res, 400, { error: "hfRepoId is required" });
9229
+ const quant = typeof body.quant === "string" ? body.quant : void 0;
9230
+ if (!deps.getRecommendations) {
9231
+ return sendJSON10(res, 503, { error: "recommendations unavailable" });
9232
+ }
9233
+ const ranked = await deps.getRecommendations({ top: RESOLVE_TOP, profile: "general" });
9234
+ const match = ranked.find(
9235
+ (r) => r.hfRepoId === hfRepoId && (quant === void 0 || r.quant === quant)
9236
+ );
9237
+ if (!match) {
9238
+ return sendJSON10(res, 404, {
9239
+ error: `no recommendation for ${hfRepoId}${quant ? ` @ ${quant}` : ""}`
9240
+ });
9241
+ }
9242
+ if (!match.ollamaName) {
9243
+ return sendJSON10(res, 422, { error: `${hfRepoId} has no known Ollama mirror to install` });
9244
+ }
9245
+ const content = {
9246
+ action: "add",
9247
+ target: { hfRepoId: match.hfRepoId, ollamaName: match.ollamaName },
9248
+ scoreDelta: match.score,
9249
+ justification: {
9250
+ summary: `Operator-initiated install of ${match.ollamaName} (${match.quant}).`,
9251
+ benchmarkBasis: [],
9252
+ hardwareFit: match.fitsHardware ? "fits detected hardware" : "may not fit detected hardware",
9253
+ evidence: match.evidence,
9254
+ freshness: `snapshot ${match.benchmarkSnapshot}`
9255
+ },
9256
+ // 0 → the installer resolves the true on-disk size via `inspect` before the
9257
+ // budget check, rather than trusting an estimate.
9258
+ diskImpactGb: 0
9259
+ };
9260
+ const record = await (0, import_core11.createModelProposal)(deps.projectPath, content, {
9261
+ proposedBy: decidedByOf(deps, req)
9262
+ });
9263
+ const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9264
+ if (outcome.status === "approved") {
9265
+ const result = {
9266
+ disposition: "installed",
9267
+ proposalId: record.id,
9268
+ evicted: outcome.evicted.map((e) => e.ollamaName)
9269
+ };
9270
+ return sendJSON10(res, 200, result);
9271
+ }
9272
+ if (outcome.status === "failed_target_missing") {
9273
+ return sendJSON10(res, 404, {
9274
+ error: `${hfRepoId} is no longer available on HuggingFace`,
9275
+ proposalId: record.id
9276
+ });
9277
+ }
9278
+ const status = outcome.code === "not_allowed" || outcome.code === "budget_exceeded" ? 409 : 502;
9279
+ return sendJSON10(res, status, {
9280
+ error: outcome.message,
9281
+ code: outcome.code,
9282
+ proposalId: record.id
9283
+ });
9284
+ }
9285
+ async function handleRemove(req, res, deps) {
9286
+ const pool = deps.getModelPool();
9287
+ if (pool === null) return sendJSON10(res, 503, { error: "LMLM disabled" });
9288
+ const body = await readJsonBody(req);
9289
+ if (body === void 0) return sendJSON10(res, 400, { error: "invalid JSON body" });
9290
+ const ollamaName = typeof body.ollamaName === "string" ? body.ollamaName : "";
9291
+ if (ollamaName.length === 0) return sendJSON10(res, 400, { error: "ollamaName is required" });
9292
+ const entry = pool.snapshot().entries.find((e) => e.ollamaName === ollamaName);
9293
+ if (!entry) return sendJSON10(res, 404, { error: `${ollamaName} is not in the pool` });
9294
+ const content = {
9295
+ action: "evict",
9296
+ target: { hfRepoId: entry.hfRepoId, ollamaName },
9297
+ scoreDelta: 0,
9298
+ justification: {
9299
+ summary: `Operator-initiated removal of ${ollamaName}.`,
9300
+ benchmarkBasis: [],
9301
+ hardwareFit: "",
9302
+ evidence: "operator",
9303
+ freshness: ""
9304
+ },
9305
+ diskImpactGb: entry.sizeOnDiskGb
9306
+ };
9307
+ const record = await (0, import_core11.createModelProposal)(deps.projectPath, content, {
9308
+ proposedBy: decidedByOf(deps, req)
9309
+ });
9310
+ const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9311
+ if (outcome.status === "error") {
9312
+ return sendJSON10(res, 502, {
9313
+ error: outcome.message,
9314
+ code: outcome.code,
9315
+ proposalId: record.id
9316
+ });
9317
+ }
9318
+ if (outcome.status === "failed_target_missing") {
9319
+ return sendJSON10(res, 500, {
9320
+ error: "unexpected failed_target_missing on evict",
9321
+ proposalId: record.id
9322
+ });
9323
+ }
9324
+ if (outcome.evicted.length > 0) {
9325
+ const result2 = {
9326
+ disposition: "removed",
9327
+ proposalId: record.id,
9328
+ evicted: outcome.evicted.map((e) => e.ollamaName)
9329
+ };
9330
+ return sendJSON10(res, 200, result2);
9331
+ }
9332
+ const stillPresent = pool.snapshot().entries.some((e) => e.ollamaName === ollamaName);
9333
+ if (stillPresent) {
9334
+ const result2 = {
9335
+ disposition: "deferred",
9336
+ proposalId: record.id,
9337
+ evicted: [],
9338
+ message: "model is in use; it will be removed after the current run"
9339
+ };
9340
+ return sendJSON10(res, 202, result2);
9341
+ }
9342
+ const result = { disposition: "removed", proposalId: record.id, evicted: [] };
9343
+ return sendJSON10(res, 200, result);
9344
+ }
9345
+ function handleV1LocalModelsMutationRoute(req, res, deps) {
9346
+ const url = req.url ?? "";
9347
+ if ((req.method ?? "GET") !== "POST") return false;
9348
+ if (INSTALL_RE.test(url)) {
9349
+ void handleInstall(req, res, deps);
9350
+ return true;
9351
+ }
9352
+ if (REMOVE_RE.test(url)) {
9353
+ void handleRemove(req, res, deps);
9354
+ return true;
9355
+ }
9356
+ return false;
9357
+ }
9358
+
8815
9359
  // src/server/routes/v1/routing.ts
8816
9360
  var import_zod14 = require("zod");
8817
9361
  var CONFIG_RE = /^\/api\/v1\/routing\/config(?:\?.*)?$/;
8818
9362
  var DECISIONS_RE = /^\/api\/v1\/routing\/decisions(?:\?.*)?$/;
8819
9363
  var TRACE_RE = /^\/api\/v1\/routing\/trace(?:\?.*)?$/;
8820
- function sendJSON9(res, status, body) {
9364
+ function sendJSON11(res, status, body) {
8821
9365
  res.writeHead(status, { "Content-Type": "application/json" });
8822
9366
  res.end(JSON.stringify(body));
8823
9367
  }
8824
9368
  function unavailable(res) {
8825
- sendJSON9(res, 503, { error: "BackendRouter not available" });
9369
+ sendJSON11(res, 503, { error: "BackendRouter not available" });
8826
9370
  return true;
8827
9371
  }
8828
9372
  function resolveChain(value, backends) {
@@ -8859,7 +9403,7 @@ function buildResolvedChains(routing, backends) {
8859
9403
  }
8860
9404
  function handleConfig(res, deps) {
8861
9405
  if (!deps.router || !deps.routing || !deps.backends) return unavailable(res);
8862
- sendJSON9(res, 200, {
9406
+ sendJSON11(res, 200, {
8863
9407
  routing: deps.routing,
8864
9408
  resolvedChains: buildResolvedChains(deps.routing, deps.backends),
8865
9409
  backends: Object.keys(deps.backends)
@@ -8887,7 +9431,7 @@ function parseDecisionsQuery(url) {
8887
9431
  function handleDecisions(req, res, deps) {
8888
9432
  if (!deps.bus) return unavailable(res);
8889
9433
  const filter = parseDecisionsQuery(req.url ?? "");
8890
- sendJSON9(res, 200, { decisions: deps.bus.recent(filter) });
9434
+ sendJSON11(res, 200, { decisions: deps.bus.recent(filter) });
8891
9435
  return true;
8892
9436
  }
8893
9437
  var UseCaseSchema = import_zod14.z.discriminatedUnion("kind", [
@@ -8919,19 +9463,19 @@ async function handleTrace(req, res, deps) {
8919
9463
  try {
8920
9464
  raw = await readBody(req);
8921
9465
  } catch {
8922
- sendJSON9(res, 400, { error: "body read failed" });
9466
+ sendJSON11(res, 400, { error: "body read failed" });
8923
9467
  return true;
8924
9468
  }
8925
9469
  let parsed;
8926
9470
  try {
8927
9471
  parsed = JSON.parse(raw);
8928
9472
  } catch {
8929
- sendJSON9(res, 400, { error: "invalid JSON body" });
9473
+ sendJSON11(res, 400, { error: "invalid JSON body" });
8930
9474
  return true;
8931
9475
  }
8932
9476
  const r = TraceBodySchema.safeParse(parsed);
8933
9477
  if (!r.success) {
8934
- sendJSON9(res, 400, { error: r.error.message });
9478
+ sendJSON11(res, 400, { error: r.error.message });
8935
9479
  return true;
8936
9480
  }
8937
9481
  const opts = r.data.invocationOverride !== void 0 ? { invocationOverride: r.data.invocationOverride } : void 0;
@@ -8944,9 +9488,9 @@ async function handleTrace(req, res, deps) {
8944
9488
  r.data.useCase,
8945
9489
  opts
8946
9490
  );
8947
- sendJSON9(res, 200, { decision, def: { type: def.type } });
9491
+ sendJSON11(res, 200, { decision, def: { type: def.type } });
8948
9492
  } catch (err) {
8949
- sendJSON9(res, 500, { error: String(err) });
9493
+ sendJSON11(res, 500, { error: String(err) });
8950
9494
  }
8951
9495
  return true;
8952
9496
  }
@@ -9183,7 +9727,7 @@ var CreateBodySchema = import_zod16.z.object({
9183
9727
  tenantId: import_zod16.z.string().optional(),
9184
9728
  expiresAt: import_zod16.z.string().datetime().optional()
9185
9729
  });
9186
- function sendJSON10(res, status, body) {
9730
+ function sendJSON12(res, status, body) {
9187
9731
  res.writeHead(status, { "Content-Type": "application/json" });
9188
9732
  res.end(JSON.stringify(body));
9189
9733
  }
@@ -9193,19 +9737,19 @@ async function handlePost(req, res, store) {
9193
9737
  raw = await readBody(req);
9194
9738
  } catch (err) {
9195
9739
  const msg = err instanceof Error ? err.message : "Failed to read body";
9196
- sendJSON10(res, 413, { error: msg });
9740
+ sendJSON12(res, 413, { error: msg });
9197
9741
  return;
9198
9742
  }
9199
9743
  let json;
9200
9744
  try {
9201
9745
  json = JSON.parse(raw);
9202
9746
  } catch {
9203
- sendJSON10(res, 400, { error: "Invalid JSON body" });
9747
+ sendJSON12(res, 400, { error: "Invalid JSON body" });
9204
9748
  return;
9205
9749
  }
9206
9750
  const parsed = CreateBodySchema.safeParse(json);
9207
9751
  if (!parsed.success) {
9208
- sendJSON10(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9752
+ sendJSON12(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9209
9753
  return;
9210
9754
  }
9211
9755
  try {
@@ -9218,37 +9762,37 @@ async function handlePost(req, res, store) {
9218
9762
  if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
9219
9763
  const result = await store.create(input);
9220
9764
  const publicRecord = import_types25.AuthTokenPublicSchema.parse(result.record);
9221
- sendJSON10(res, 200, {
9765
+ sendJSON12(res, 200, {
9222
9766
  ...publicRecord,
9223
9767
  token: result.token
9224
9768
  });
9225
9769
  } catch (err) {
9226
9770
  const msg = err instanceof Error ? err.message : "Failed to create token";
9227
9771
  if (msg.includes("already exists")) {
9228
- sendJSON10(res, 409, { error: msg });
9772
+ sendJSON12(res, 409, { error: msg });
9229
9773
  return;
9230
9774
  }
9231
- sendJSON10(res, 500, { error: "Internal error creating token" });
9775
+ sendJSON12(res, 500, { error: "Internal error creating token" });
9232
9776
  }
9233
9777
  }
9234
9778
  async function handleList3(res, store) {
9235
9779
  try {
9236
9780
  const list = await store.list();
9237
- sendJSON10(res, 200, list);
9781
+ sendJSON12(res, 200, list);
9238
9782
  } catch {
9239
- sendJSON10(res, 500, { error: "Internal error listing tokens" });
9783
+ sendJSON12(res, 500, { error: "Internal error listing tokens" });
9240
9784
  }
9241
9785
  }
9242
9786
  async function handleDelete2(res, store, id) {
9243
9787
  try {
9244
9788
  const ok = await store.revoke(id);
9245
9789
  if (!ok) {
9246
- sendJSON10(res, 404, { error: "Token not found" });
9790
+ sendJSON12(res, 404, { error: "Token not found" });
9247
9791
  return;
9248
9792
  }
9249
- sendJSON10(res, 200, { deleted: true });
9793
+ sendJSON12(res, 200, { deleted: true });
9250
9794
  } catch {
9251
- sendJSON10(res, 500, { error: "Internal error revoking token" });
9795
+ sendJSON12(res, 500, { error: "Internal error revoking token" });
9252
9796
  }
9253
9797
  }
9254
9798
  var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
@@ -9273,12 +9817,12 @@ function handleAuthRoute(req, res, store) {
9273
9817
  return true;
9274
9818
  }
9275
9819
  }
9276
- sendJSON10(res, 405, { error: "Method not allowed" });
9820
+ sendJSON12(res, 405, { error: "Method not allowed" });
9277
9821
  return true;
9278
9822
  }
9279
9823
 
9280
9824
  // src/server/routes/local-model.ts
9281
- function sendJSON11(res, status, body) {
9825
+ function sendJSON13(res, status, body) {
9282
9826
  res.writeHead(status, { "Content-Type": "application/json" });
9283
9827
  res.end(JSON.stringify(body));
9284
9828
  }
@@ -9286,30 +9830,30 @@ function handleLocalModelRoute(req, res, getStatus) {
9286
9830
  const { method, url } = req;
9287
9831
  if (url !== "/api/v1/local-model/status") return false;
9288
9832
  if (method !== "GET") {
9289
- sendJSON11(res, 405, { error: "Method not allowed" });
9833
+ sendJSON13(res, 405, { error: "Method not allowed" });
9290
9834
  return true;
9291
9835
  }
9292
9836
  if (!getStatus) {
9293
- sendJSON11(res, 503, { error: "Local backend not configured" });
9837
+ sendJSON13(res, 503, { error: "Local backend not configured" });
9294
9838
  return true;
9295
9839
  }
9296
9840
  const status = getStatus();
9297
9841
  if (!status) {
9298
- sendJSON11(res, 503, { error: "Local backend not configured" });
9842
+ sendJSON13(res, 503, { error: "Local backend not configured" });
9299
9843
  return true;
9300
9844
  }
9301
- sendJSON11(res, 200, status);
9845
+ sendJSON13(res, 200, status);
9302
9846
  return true;
9303
9847
  }
9304
9848
  function handleLocalModelsRoute(req, res, getStatuses) {
9305
9849
  const { method, url } = req;
9306
9850
  if (url !== "/api/v1/local-models/status") return false;
9307
9851
  if (method !== "GET") {
9308
- sendJSON11(res, 405, { error: "Method not allowed" });
9852
+ sendJSON13(res, 405, { error: "Method not allowed" });
9309
9853
  return true;
9310
9854
  }
9311
9855
  const statuses = getStatuses ? getStatuses() : [];
9312
- sendJSON11(res, 200, statuses);
9856
+ sendJSON13(res, 200, statuses);
9313
9857
  return true;
9314
9858
  }
9315
9859
 
@@ -9673,6 +10217,60 @@ var V1_BRIDGE_ROUTES = [
9673
10217
  scope: "read-telemetry",
9674
10218
  description: "Prompt-cache hit/miss snapshot (rolling window)."
9675
10219
  },
10220
+ // ── LMLM Phase 6 — force-refresh (background scheduler tick, out of band) ──
10221
+ // Reuses `manage-proposals`: a forced refresh emits model proposals, so the
10222
+ // same write scope that governs approve/reject governs triggering the tick.
10223
+ {
10224
+ method: "POST",
10225
+ pattern: /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/,
10226
+ scope: "manage-proposals",
10227
+ description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
10228
+ },
10229
+ // ── LMLM dashboard pool mutation — operator-initiated install/remove ──
10230
+ // Modeled as auto-approved model proposals, so the same `manage-proposals`
10231
+ // write scope that governs approve/reject governs these too.
10232
+ {
10233
+ method: "POST",
10234
+ pattern: /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/,
10235
+ scope: "manage-proposals",
10236
+ description: "Install a recommended model into the local pool (auto-approved proposal)."
10237
+ },
10238
+ {
10239
+ method: "POST",
10240
+ pattern: /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/,
10241
+ scope: "manage-proposals",
10242
+ description: "Remove a model from the local pool (auto-approved proposal)."
10243
+ },
10244
+ // ── LMLM Phase 7 — read surface (hardware/pool/recommendations/proposals) ──
10245
+ // Load-bearing: `local-models` is in `V1_WRAPPABLE`, so without these entries
10246
+ // the `/api/v1` rewrite shim would rewrite `/api/v1/local-models/<name>` →
10247
+ // `/api/local-models/<name>` and misroute it to the legacy status handler.
10248
+ // `isV1Bridge` short-circuits the rewrite; `requiredBridgeScope` supplies the
10249
+ // default-deny read scope. All four are read-only observability (`read-status`).
10250
+ {
10251
+ method: "GET",
10252
+ pattern: /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/,
10253
+ scope: "read-status",
10254
+ description: "Current detected HardwareProfile (RAM/VRAM/GPU)."
10255
+ },
10256
+ {
10257
+ method: "GET",
10258
+ pattern: /^\/api\/v1\/local-models\/pool(?:\?.*)?$/,
10259
+ scope: "read-status",
10260
+ description: "Live PoolState view, including transient pendingEviction flags."
10261
+ },
10262
+ {
10263
+ method: "GET",
10264
+ pattern: /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/,
10265
+ scope: "read-status",
10266
+ description: "Hardware-ranked model recommendations (top/profile query params)."
10267
+ },
10268
+ {
10269
+ method: "GET",
10270
+ pattern: /^\/api\/v1\/local-models\/proposals(?:\?.*)?$/,
10271
+ scope: "read-status",
10272
+ description: "Open model-kind proposals (pending review queue)."
10273
+ },
9676
10274
  // ── Spec B Phase 5 routing observability ──
9677
10275
  // D-OP-1: all three reuse `read-telemetry` — matches the cacheMetrics
9678
10276
  // precedent (read-only observability). A dedicated `read-routing`
@@ -9815,7 +10413,19 @@ var OrchestratorServer = class {
9815
10413
  getRoutingDecisionBusFn = null;
9816
10414
  getRoutingConfigFn = null;
9817
10415
  getBackendsFn = null;
10416
+ // LMLM Phase 6 — live model pool + refresh scheduler accessors.
10417
+ getModelPoolFn = null;
10418
+ getRefreshSchedulerFn = null;
10419
+ // LMLM Phase 7 — hardware / recommendations / model-proposal read accessors.
10420
+ getHardwareProfileFn = null;
10421
+ getRecommendationsFn = null;
10422
+ listModelProposalsFn = null;
10423
+ // LMLM Phase 7 / S1 — in-use probe threaded into kind:'model' approve.
10424
+ isModelInUseFn = null;
9818
10425
  routingDecisionUnsubscribe = null;
10426
+ // LMLM Phase 7 — bus→WS fan-out listeners for the model proposal/pool topics.
10427
+ modelProposalListener = null;
10428
+ modelPoolListener = null;
9819
10429
  recorder = null;
9820
10430
  planWatcher = null;
9821
10431
  tokenStore;
@@ -9860,6 +10470,12 @@ var OrchestratorServer = class {
9860
10470
  this.getRoutingDecisionBusFn = deps?.getRoutingDecisionBus ?? null;
9861
10471
  this.getRoutingConfigFn = deps?.getRoutingConfig ?? null;
9862
10472
  this.getBackendsFn = deps?.getBackends ?? null;
10473
+ this.getModelPoolFn = deps?.getModelPool ?? null;
10474
+ this.getRefreshSchedulerFn = deps?.getRefreshScheduler ?? null;
10475
+ this.getHardwareProfileFn = deps?.getHardwareProfile ?? null;
10476
+ this.getRecommendationsFn = deps?.getRecommendations ?? null;
10477
+ this.listModelProposalsFn = deps?.listModelProposals ?? null;
10478
+ this.isModelInUseFn = deps?.isModelInUse ?? null;
9863
10479
  }
9864
10480
  wireEvents() {
9865
10481
  this.stateChangeListener = (snapshot) => {
@@ -9876,6 +10492,10 @@ var OrchestratorServer = class {
9876
10492
  this.broadcaster.broadcast("routing:decision", decision);
9877
10493
  });
9878
10494
  }
10495
+ this.modelProposalListener = (data) => this.broadcaster.broadcast(MODEL_PROPOSAL_TOPIC, data);
10496
+ this.modelPoolListener = (data) => this.broadcaster.broadcast(MODEL_POOL_TOPIC, data);
10497
+ this.orchestrator.on(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
10498
+ this.orchestrator.on(MODEL_POOL_TOPIC, this.modelPoolListener);
9879
10499
  }
9880
10500
  /**
9881
10501
  * Broadcast a new interaction to all WebSocket clients.
@@ -10044,9 +10664,38 @@ var OrchestratorServer = class {
10044
10664
  // upstream by V1_BRIDGE_ROUTES; this dispatcher only handles
10045
10665
  // business logic. `projectPath` defaults to process.cwd() — that is
10046
10666
  // where `.harness/proposals/` lives in every deployment we ship.
10047
- (req, res) => handleV1ProposalsRoute(req, res, {
10667
+ (req, res) => {
10668
+ const modelPool = this.getModelPoolFn?.() ?? null;
10669
+ return handleV1ProposalsRoute(req, res, {
10670
+ projectPath: this.projectPath,
10671
+ bus: this.orchestrator,
10672
+ ...modelPool ? { modelPool } : {},
10673
+ // S1: thread the in-use probe so an approved swap/evict of a model an
10674
+ // agent could be using is deferred (marked pendingEviction) not applied.
10675
+ ...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
10676
+ });
10677
+ },
10678
+ // LMLM dashboard pool mutation — POST /pool/install + /pool/remove.
10679
+ // Operator-initiated install/remove via auto-approved proposals; reuses
10680
+ // the same mutation pool + bus + in-use probe the proposals route uses.
10681
+ // Registered before the read surface so /pool/install|remove match first.
10682
+ (req, res) => handleV1LocalModelsMutationRoute(req, res, {
10048
10683
  projectPath: this.projectPath,
10049
- bus: this.orchestrator
10684
+ bus: this.orchestrator,
10685
+ getModelPool: () => this.getModelPoolFn?.() ?? null,
10686
+ ...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
10687
+ ...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
10688
+ }),
10689
+ // LMLM Phase 6/7 — POST /refresh + the GET read surface
10690
+ // (hardware/pool/recommendations/proposals). Registered before the
10691
+ // chat-proxy fallback so it owns the path. Each accessor is spread in
10692
+ // only when configured so absent ones surface as 503 (LMLM disabled).
10693
+ (req, res) => handleV1LocalModelsRoute(req, res, {
10694
+ getRefreshScheduler: this.getRefreshSchedulerFn ?? (() => null),
10695
+ getModelPool: () => this.getModelPoolFn?.() ?? null,
10696
+ ...this.getHardwareProfileFn ? { getHardwareProfile: this.getHardwareProfileFn } : {},
10697
+ ...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
10698
+ ...this.listModelProposalsFn ? { listModelProposals: this.listModelProposalsFn } : {}
10050
10699
  }),
10051
10700
  // Chat proxy route (spawns Claude Code CLI — no API key required)
10052
10701
  (req, res) => handleChatProxyRoute(req, res, this.claudeCommand)
@@ -10130,7 +10779,7 @@ var OrchestratorServer = class {
10130
10779
  return this.broadcaster.clientCount;
10131
10780
  }
10132
10781
  async start() {
10133
- (0, import_core11.assertPortUsable)(this.port, "orchestrator");
10782
+ (0, import_core12.assertPortUsable)(this.port, "orchestrator");
10134
10783
  if (this.interactionQueue) {
10135
10784
  this.planWatcher = new PlanWatcher(this.plansDir, this.interactionQueue);
10136
10785
  this.planWatcher.start();
@@ -10150,6 +10799,14 @@ var OrchestratorServer = class {
10150
10799
  this.routingDecisionUnsubscribe();
10151
10800
  this.routingDecisionUnsubscribe = null;
10152
10801
  }
10802
+ if (this.modelProposalListener) {
10803
+ this.orchestrator.removeListener(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
10804
+ this.modelProposalListener = null;
10805
+ }
10806
+ if (this.modelPoolListener) {
10807
+ this.orchestrator.removeListener(MODEL_POOL_TOPIC, this.modelPoolListener);
10808
+ this.modelPoolListener = null;
10809
+ }
10153
10810
  if (this.planWatcher) {
10154
10811
  this.planWatcher.stop();
10155
10812
  this.planWatcher = null;
@@ -10624,7 +11281,7 @@ function wireWebhookFanout({ bus, store, delivery }) {
10624
11281
 
10625
11282
  // src/gateway/telemetry/fanout.ts
10626
11283
  var import_node_crypto13 = require("crypto");
10627
- var import_core12 = require("@harness-engineering/core");
11284
+ var import_core13 = require("@harness-engineering/core");
10628
11285
  var TOPICS = {
10629
11286
  MAINTENANCE_STARTED: "maintenance:started",
10630
11287
  MAINTENANCE_COMPLETED: "maintenance:completed",
@@ -10756,7 +11413,7 @@ function buildSpan(topic, plan, payload) {
10756
11413
  spanId: plan.spanId,
10757
11414
  ...plan.parentSpanId !== void 0 ? { parentSpanId: plan.parentSpanId } : {},
10758
11415
  name: SPAN_NAME[topic],
10759
- kind: import_core12.SpanKind.INTERNAL,
11416
+ kind: import_core13.SpanKind.INTERNAL,
10760
11417
  startTimeNs: startNs,
10761
11418
  endTimeNs: startNs,
10762
11419
  attributes: buildAttributes(payload, { "harness.topic": topic }),
@@ -11065,8 +11722,50 @@ var ENVELOPE_DERIVERS = {
11065
11722
  summary: truncate(data.reason ?? "No reason provided.", 240),
11066
11723
  severity: "warning"
11067
11724
  };
11068
- }
11725
+ },
11726
+ // LMLM Phase 7 — model-proposal lifecycle. Delegated to a status-keyed
11727
+ // lookup (below) so each status branch is its own small function and the
11728
+ // deriver stays under the cyclomatic-complexity gate.
11729
+ "local-models.proposal": (event) => deriveModelProposalEnvelope(event)
11069
11730
  };
11731
+ var MODEL_PROPOSAL_ENVELOPES = {
11732
+ created: (data) => {
11733
+ const label = `${data.action ?? "change"} ${data.target ?? "(unknown model)"}`;
11734
+ return {
11735
+ title: `New model proposal: ${label}`,
11736
+ summary: `A model proposal (${label}) is awaiting review.`,
11737
+ severity: "info"
11738
+ };
11739
+ },
11740
+ approved: (data) => {
11741
+ const label = `${data.action ?? "change"} ${data.target ?? "(unknown model)"}`;
11742
+ return {
11743
+ title: `Model proposal approved: ${label}`,
11744
+ summary: `The model proposal (${label}) was approved and applied to the pool.`,
11745
+ severity: "success"
11746
+ };
11747
+ },
11748
+ rejected: (data) => ({
11749
+ title: "Model proposal rejected",
11750
+ summary: truncate(data.reason ?? "No reason provided.", 240),
11751
+ severity: "warning"
11752
+ }),
11753
+ failed_target_missing: (data) => ({
11754
+ title: "Model proposal target missing",
11755
+ summary: `The proposed model ${data.target ?? "(unknown model)"} is no longer available; the proposal was closed.`,
11756
+ severity: "error"
11757
+ })
11758
+ };
11759
+ function deriveModelProposalEnvelope(event) {
11760
+ const data = asObj(event.data);
11761
+ const build = MODEL_PROPOSAL_ENVELOPES[data.status ?? ""];
11762
+ if (build) return build(data);
11763
+ return {
11764
+ title: `Model proposal update: ${data.target ?? "(unknown model)"}`,
11765
+ summary: `Model proposal status: ${data.status ?? "(unknown)"}.`,
11766
+ severity: "info"
11767
+ };
11768
+ }
11070
11769
  function truncate(s, max) {
11071
11770
  return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
11072
11771
  }
@@ -11113,7 +11812,9 @@ var NOTIFICATION_TOPICS = [
11113
11812
  // Hermes Phase 4 — skill proposal lifecycle.
11114
11813
  "proposal.created",
11115
11814
  "proposal.approved",
11116
- "proposal.rejected"
11815
+ "proposal.rejected",
11816
+ // LMLM Phase 7 — model-proposal lifecycle (colon→dot ⇒ 'local-models.proposal').
11817
+ "local-models:proposal"
11117
11818
  ];
11118
11819
  function newEventId3() {
11119
11820
  return `evt_${(0, import_node_crypto14.randomBytes)(8).toString("hex")}`;
@@ -11169,7 +11870,7 @@ function wireNotificationSinks({ bus, registry }) {
11169
11870
  }
11170
11871
 
11171
11872
  // src/orchestrator.ts
11172
- var import_core17 = require("@harness-engineering/core");
11873
+ var import_core19 = require("@harness-engineering/core");
11173
11874
 
11174
11875
  // src/logging/logger.ts
11175
11876
  var StructuredLogger = class {
@@ -11211,7 +11912,7 @@ var StructuredLogger = class {
11211
11912
  // src/workspace/config-scanner.ts
11212
11913
  var import_node_fs = require("fs");
11213
11914
  var import_node_path4 = require("path");
11214
- var import_core13 = require("@harness-engineering/core");
11915
+ var import_core14 = require("@harness-engineering/core");
11215
11916
  var CONFIG_FILES = ["CLAUDE.md", "AGENTS.md", ".gemini/settings.json", "skill.yaml"];
11216
11917
  var BLOCKING_INJECTION_PREFIXES = ["INJ-UNI-", "INJ-REROL-"];
11217
11918
  var DOWNGRADED_SECURITY_RULES = /* @__PURE__ */ new Set(["SEC-AGT-006"]);
@@ -11233,29 +11934,29 @@ async function scanSingleFile(filePath, targetDir, scanner) {
11233
11934
  } catch {
11234
11935
  return null;
11235
11936
  }
11236
- const injectionFindings = (0, import_core13.scanForInjection)(content);
11237
- const findings = (0, import_core13.mapInjectionFindings)(injectionFindings);
11937
+ const injectionFindings = (0, import_core14.scanForInjection)(content);
11938
+ const findings = (0, import_core14.mapInjectionFindings)(injectionFindings);
11238
11939
  const secFindings = await scanner.scanFile(filePath);
11239
- findings.push(...(0, import_core13.mapSecurityFindings)(secFindings, findings));
11940
+ findings.push(...(0, import_core14.mapSecurityFindings)(secFindings, findings));
11240
11941
  const adjusted = adjustFindingSeverity(findings);
11241
11942
  return {
11242
11943
  file: (0, import_node_path4.relative)(targetDir, filePath).replaceAll("\\", "/"),
11243
11944
  findings: adjusted,
11244
- overallSeverity: (0, import_core13.computeOverallSeverity)(adjusted)
11945
+ overallSeverity: (0, import_core14.computeOverallSeverity)(adjusted)
11245
11946
  };
11246
11947
  }
11247
11948
  async function scanWorkspaceConfig(workspacePath) {
11248
- const scanner = new import_core13.SecurityScanner((0, import_core13.parseSecurityConfig)({}));
11949
+ const scanner = new import_core14.SecurityScanner((0, import_core14.parseSecurityConfig)({}));
11249
11950
  const results = [];
11250
11951
  for (const configFile of CONFIG_FILES) {
11251
11952
  const result = await scanSingleFile((0, import_node_path4.join)(workspacePath, configFile), workspacePath, scanner);
11252
11953
  if (result) results.push(result);
11253
11954
  }
11254
- return { exitCode: (0, import_core13.computeScanExitCode)(results), results };
11955
+ return { exitCode: (0, import_core14.computeScanExitCode)(results), results };
11255
11956
  }
11256
11957
 
11257
11958
  // src/core/lane-persistence.ts
11258
- var import_core14 = require("@harness-engineering/core");
11959
+ var import_core15 = require("@harness-engineering/core");
11259
11960
  var import_types29 = require("@harness-engineering/types");
11260
11961
  var SIGNAL_TO_LANE = {
11261
11962
  claim: "claimed",
@@ -11269,16 +11970,16 @@ function mapOrchestratorLane(signal) {
11269
11970
  }
11270
11971
  async function persistLane(projectPath, issueId, signal) {
11271
11972
  try {
11272
- const reg = await import_core14.eventSourcing.registerTask(projectPath, issueId, []);
11973
+ const reg = await import_core15.eventSourcing.registerTask(projectPath, issueId, []);
11273
11974
  if (!reg.ok) return reg;
11274
- return await import_core14.eventSourcing.transitionLane(projectPath, issueId, mapOrchestratorLane(signal));
11975
+ return await import_core15.eventSourcing.transitionLane(projectPath, issueId, mapOrchestratorLane(signal));
11275
11976
  } catch (err) {
11276
11977
  return (0, import_types29.Err)(err instanceof Error ? err : new Error(String(err)));
11277
11978
  }
11278
11979
  }
11279
11980
  async function readPersistedLanes(projectPath) {
11280
11981
  try {
11281
- const snap = await import_core14.eventSourcing.readSnapshot(projectPath);
11982
+ const snap = await import_core15.eventSourcing.readSnapshot(projectPath);
11282
11983
  return snap.ok ? snap.value.lanes : { tasks: {} };
11283
11984
  } catch {
11284
11985
  return { tasks: {} };
@@ -13198,6 +13899,45 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13198
13899
  * so this map is the single source of truth post-migration.
13199
13900
  */
13200
13901
  localResolvers = /* @__PURE__ */ new Map();
13902
+ /** Phase 4 (D5): pool-state port shared by all local/pi resolvers. Null when LMLM disabled. */
13903
+ poolStateProvider = null;
13904
+ poolStateStore = null;
13905
+ /**
13906
+ * LMLM Phase 6: live model pool + its installer. Constructed only when
13907
+ * `localModels.enabled` and a real `PoolStateStore` exists (not a test
13908
+ * override). Exposed to the server via `getModelPool()`, which retires the
13909
+ * proposals-route 501 stub for `kind: 'model'` approve/reject. Null when LMLM
13910
+ * is disabled. `PoolManager` reads `store.snapshot()` lazily, so constructing
13911
+ * before `store.load()` (in initLocalModelAndPipeline) is safe.
13912
+ */
13913
+ modelPool = null;
13914
+ modelInstaller = null;
13915
+ /**
13916
+ * S1 drain re-entrancy guard (P7-SUG-DRAIN-REENTRANCY). `drainDeferredEvictions`
13917
+ * is fired fire-and-forget from `emitWorkerExit` (and, since P7-SUG-DRAIN-LIVENESS,
13918
+ * piggybacked on each refresh tick). Two overlapping drains would both read the
13919
+ * same `listPendingEvictions()` snapshot, both re-check `isLocalModelInUse`, and
13920
+ * both `await pool.evict` the SAME name — double-calling the installer and
13921
+ * broadcasting a duplicate `evict_completed` frame. The single-threaded event
13922
+ * loop makes a plain boolean sufficient: a drain that arrives while one is
13923
+ * running returns early rather than double-processing.
13924
+ */
13925
+ draining = false;
13926
+ /**
13927
+ * LMLM Phase 6: single per-instance background refresh scheduler. Started in
13928
+ * `initLocalModelAndPipeline` when the pool exists; stopped in `stop()`. Null
13929
+ * when LMLM is disabled. Exposed to the server via `getRefreshScheduler()`.
13930
+ */
13931
+ refreshScheduler = null;
13932
+ /**
13933
+ * LMLM Phase 7: the hardware-aware recommender bound at scheduler start. Reused
13934
+ * by `GET /api/v1/local-models/recommendations`. Null when LMLM is disabled (no
13935
+ * pool → scheduler never armed). Ranks the (currently empty) candidate set —
13936
+ * see the Phase 2 candidate-parser gap noted on `startRefreshScheduler`.
13937
+ */
13938
+ modelRecommender = null;
13939
+ /** Test seam: injected timer/clock for the scheduler so no real 24h timer runs. */
13940
+ schedulerTimerOverride;
13201
13941
  /**
13202
13942
  * Spec B Phase 3: skill catalog (name + cognitiveMode) read once at
13203
13943
  * construction from `projectRoot/agents/skills/`. Consulted by
@@ -13288,6 +14028,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13288
14028
  */
13289
14029
  constructor(config, promptTemplate, overrides) {
13290
14030
  super();
14031
+ this.schedulerTimerOverride = overrides?.schedulerTimer ?? null;
13291
14032
  this.setMaxListeners(50);
13292
14033
  this.config = config;
13293
14034
  this.promptTemplate = promptTemplate;
@@ -13335,6 +14076,16 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13335
14076
  this
13336
14077
  );
13337
14078
  this.analysisArchive = new AnalysisArchive(path21.join(config.workspace.root, "..", "analyses"));
14079
+ const localModelsEnabled = this.config.localModels?.enabled === true;
14080
+ if (overrides?.poolState) {
14081
+ this.poolStateProvider = overrides.poolState;
14082
+ } else if (localModelsEnabled) {
14083
+ this.poolStateStore = new import_local_models4.PoolStateStore({
14084
+ onWarn: (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0)
14085
+ });
14086
+ this.poolStateProvider = this.poolStateStore;
14087
+ this.initModelPool(this.poolStateStore);
14088
+ }
13338
14089
  const backendsMap = this.config.agent.backends ?? {};
13339
14090
  for (const [name, def] of Object.entries(backendsMap)) {
13340
14091
  if (def.type === "local" || def.type === "pi") {
@@ -13345,10 +14096,11 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13345
14096
  };
13346
14097
  if (def.apiKey !== void 0) resolverOpts.apiKey = def.apiKey;
13347
14098
  if (def.probeIntervalMs !== void 0) resolverOpts.probeIntervalMs = def.probeIntervalMs;
14099
+ if (this.poolStateProvider !== null) resolverOpts.poolState = this.poolStateProvider;
13348
14100
  this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
13349
14101
  }
13350
14102
  }
13351
- this.cacheMetrics = new import_core17.CacheMetricsRecorder();
14103
+ this.cacheMetrics = new import_core19.CacheMetricsRecorder();
13352
14104
  if (this.config.agent.backends !== void 0 && Object.keys(this.config.agent.backends).length > 0) {
13353
14105
  const sandboxPolicy = this.config.agent.sandboxPolicy === "docker" ? "docker" : "none";
13354
14106
  const firstBackendName = Object.keys(this.config.agent.backends)[0];
@@ -13456,7 +14208,26 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13456
14208
  roadmapPath: config.tracker.filePath ?? null,
13457
14209
  dispatchAdHoc: this.dispatchAdHoc.bind(this),
13458
14210
  getLocalModelStatus: () => this.getFirstLocalModelStatus(),
13459
- getLocalModelStatuses: () => this.buildLocalModelStatuses()
14211
+ getLocalModelStatuses: () => this.buildLocalModelStatuses(),
14212
+ // LMLM Phase 6: expose the live pool so kind:'model' approve/reject
14213
+ // reaches PoolManager (retiring the 501). Null when LMLM is disabled.
14214
+ getModelPool: () => this.modelPool,
14215
+ // LMLM Phase 7 / S1: conservative in-use probe so an approved swap/evict
14216
+ // of a model an agent could be using is DEFERRED, not applied mid-request
14217
+ // (ADR 0060). Agent-run-coarse; may over-defer (safe).
14218
+ isModelInUse: (ollamaName) => this.isLocalModelInUse(ollamaName),
14219
+ // LMLM Phase 6: expose the refresh scheduler for POST /local-models/refresh.
14220
+ getRefreshScheduler: () => this.refreshScheduler,
14221
+ // LMLM Phase 7 read surface — hardware / recommendations / model proposals.
14222
+ // Each returns null/[] when LMLM is disabled so the route renders 503/[].
14223
+ getHardwareProfile: () => this.modelPool ? this.detectLmlmHardware() : null,
14224
+ getRecommendations: async ({ top }) => {
14225
+ if (this.modelRecommender === null) return [];
14226
+ const hardware = await this.detectLmlmHardware();
14227
+ const { ranked } = await this.modelRecommender(hardware);
14228
+ return ranked.slice(0, top);
14229
+ },
14230
+ listModelProposals: () => (0, import_core18.listProposals)(this.projectRoot, { status: "open", kind: "model" })
13460
14231
  });
13461
14232
  this.server.setRecorder(this.recorder);
13462
14233
  this.interactionQueue.onPush((interaction) => {
@@ -13473,7 +14244,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13473
14244
  setupTelemetryExport(config, webhookStore, webhookDelivery) {
13474
14245
  const otlpCfg = config.telemetry?.export?.otlp;
13475
14246
  if (!otlpCfg) return;
13476
- this.otlpExporter = new import_core17.OTLPExporter({
14247
+ this.otlpExporter = new import_core19.OTLPExporter({
13477
14248
  endpoint: otlpCfg.endpoint,
13478
14249
  ...otlpCfg.enabled !== void 0 ? { enabled: otlpCfg.enabled } : {},
13479
14250
  ...otlpCfg.headers !== void 0 ? { headers: otlpCfg.headers } : {},
@@ -13514,6 +14285,60 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13514
14285
  }
13515
14286
  return out;
13516
14287
  }
14288
+ /**
14289
+ * S1 conservative in-use probe (ADR 0060). Returns `true` when ANY agent run
14290
+ * is live AND `ollamaName` is a currently-resolved (or last-detected) local
14291
+ * model — i.e. an agent could be routing inference to it right now.
14292
+ *
14293
+ * This signal is AGENT-RUN-COARSE, not per-request: `state.running` is keyed
14294
+ * by GitHub issue (spawned agent runs), NOT by inference call, and no
14295
+ * per-model request counter exists today. The probe therefore MAY OVER-DEFER
14296
+ * (a swap waits until the pool is idle). Over-deferral is exactly S1's
14297
+ * intended safe failure — never yank a model mid-request; occasionally wait
14298
+ * longer than strictly necessary. A fine-grained per-request signal is an
14299
+ * explicit deferred gap (ADR 0060).
14300
+ */
14301
+ isLocalModelInUse(ollamaName) {
14302
+ if (this.state.running.size === 0) return false;
14303
+ return this.buildLocalModelStatuses().some(
14304
+ (s) => s.resolved === ollamaName || s.detected.includes(ollamaName)
14305
+ );
14306
+ }
14307
+ /**
14308
+ * S1 drain (ADR 0060): complete any eviction that was DEFERRED because its
14309
+ * target was in use, now that the probe reports it idle. Best-effort — called
14310
+ * from the run-completion path; it never blocks dispatch and swallows
14311
+ * per-model errors (a failed or still-busy evict stays pending for the next
14312
+ * drain). `pendingEviction` is a transient overlay, so a missed drain simply
14313
+ * leaves the flag set until the next completion re-checks it.
14314
+ */
14315
+ async drainDeferredEvictions() {
14316
+ const pool = this.modelPool;
14317
+ if (pool === null) return;
14318
+ if (this.draining) return;
14319
+ this.draining = true;
14320
+ try {
14321
+ for (const ollamaName of pool.listPendingEvictions()) {
14322
+ if (this.isLocalModelInUse(ollamaName)) continue;
14323
+ try {
14324
+ const result = await pool.evict({ ollamaName });
14325
+ if (result.status === "error") continue;
14326
+ pool.clearPendingEviction(ollamaName);
14327
+ this.emit("local-models:pool", {
14328
+ action: "evict",
14329
+ // XP-2: `evicted` is uniformly string[] across all local-models:pool
14330
+ // emit sites — the drain wraps its single completed eviction in an
14331
+ // array to match the swap/add multi-evict shape.
14332
+ evicted: [ollamaName],
14333
+ phase: "evict_completed"
14334
+ });
14335
+ } catch {
14336
+ }
14337
+ }
14338
+ } finally {
14339
+ this.draining = false;
14340
+ }
14341
+ }
13517
14342
  createTracker() {
13518
14343
  if (this.config.tracker.kind === "github-issues") {
13519
14344
  const trackerCfg = {
@@ -13522,7 +14347,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13522
14347
  ...this.config.tracker.apiKey ? { token: this.config.tracker.apiKey } : {},
13523
14348
  ...this.config.tracker.endpoint ? { apiBase: this.config.tracker.endpoint } : {}
13524
14349
  };
13525
- const clientResult = (0, import_core16.createTrackerClient)(trackerCfg);
14350
+ const clientResult = (0, import_core17.createTrackerClient)(trackerCfg);
13526
14351
  if (!clientResult.ok) throw clientResult.error;
13527
14352
  return new GitHubIssuesIssueTrackerAdapter(clientResult.value, this.config.tracker);
13528
14353
  }
@@ -14101,12 +14926,12 @@ ${messages}`);
14101
14926
  async postLifecycleComment(identifier, externalId, event) {
14102
14927
  try {
14103
14928
  if (!externalId) return;
14104
- const trackerConfig = (0, import_core16.loadTrackerSyncConfig)(this.projectRoot);
14929
+ const trackerConfig = (0, import_core17.loadTrackerSyncConfig)(this.projectRoot);
14105
14930
  if (!trackerConfig) return;
14106
14931
  const token = process.env.GITHUB_TOKEN;
14107
14932
  if (!token) return;
14108
14933
  const orchestratorId = await this.orchestratorIdPromise;
14109
- const adapter = new import_core16.GitHubIssuesSyncAdapter({ token, config: trackerConfig });
14934
+ const adapter = new import_core17.GitHubIssuesSyncAdapter({ token, config: trackerConfig });
14110
14935
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
14111
14936
  const actionMap = {
14112
14937
  claimed: "Dispatching agent for autonomous execution",
@@ -14180,7 +15005,7 @@ ${messages}`);
14180
15005
  ...f.line !== void 0 ? { line: f.line } : {}
14181
15006
  }))
14182
15007
  );
14183
- (0, import_core15.writeTaint)(
15008
+ (0, import_core16.writeTaint)(
14184
15009
  workspacePath,
14185
15010
  issue.id,
14186
15011
  "Medium-severity injection patterns found in workspace config files",
@@ -14360,6 +15185,7 @@ ${messages}`);
14360
15185
  error,
14361
15186
  (effect) => this.handleEffect(effect)
14362
15187
  );
15188
+ void this.drainDeferredEvictions();
14363
15189
  this.emit("state_change", this.getSnapshot());
14364
15190
  }
14365
15191
  /**
@@ -14447,6 +15273,120 @@ ${messages}`);
14447
15273
  * before constructing the intelligence pipeline. Subscribes the dashboard
14448
15274
  * broadcast stub to status changes. Called exactly once from start().
14449
15275
  */
15276
+ /**
15277
+ * LMLM Phase 6: construct the live `PoolManager` (Ollama installer + the
15278
+ * loaded pool-state store) and stash it for `getModelPool()`. Defensive
15279
+ * config fallbacks: `ollamaEndpoint → http://localhost:11434`. The pool reads
15280
+ * `store.snapshot()` lazily, so this runs safely at construction time before
15281
+ * `store.load()`.
15282
+ */
15283
+ initModelPool(store) {
15284
+ const onWarn = (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0);
15285
+ const installerCfg = this.config.localModels?.installer;
15286
+ this.modelInstaller = new import_local_models4.OllamaInstallAdapter({
15287
+ baseUrl: installerCfg?.ollamaEndpoint ?? "http://localhost:11434",
15288
+ onWarn
15289
+ });
15290
+ this.modelPool = new import_local_models4.PoolManager({ store, installer: this.modelInstaller, onWarn });
15291
+ }
15292
+ /**
15293
+ * LMLM Phase 7 wiring: apply the operator's configured pool bounds (disk
15294
+ * budget + org/family allowlist) from `localModels.pool` to the live pool.
15295
+ * Called after `PoolStateStore.load()` so config wins over persisted bounds
15296
+ * (D2, declarative precedence). No-op when the pool is null (LMLM disabled)
15297
+ * or no `pool` block is configured, so it is safe to call unconditionally
15298
+ * on the startup path.
15299
+ */
15300
+ async applyConfiguredPoolBounds() {
15301
+ const pool = this.modelPool;
15302
+ const bounds = this.config.localModels?.pool;
15303
+ if (pool === null || !bounds) return;
15304
+ await pool.configurePool({
15305
+ diskBudgetGb: bounds.diskBudgetGb,
15306
+ allowedOrgs: bounds.allowedOrgs,
15307
+ allowedFamilies: bounds.allowedFamilies
15308
+ });
15309
+ }
15310
+ /**
15311
+ * LMLM Phase 6: arm the single background refresh scheduler over the live
15312
+ * pool. No-op when LMLM is disabled (`modelPool` null). Each tick runs
15313
+ * hardware→recommend→reconcile(D12 drift)→diff→emit→score-writeback.
15314
+ *
15315
+ * NOTE (deferred): the recommender is seeded with an empty candidate set —
15316
+ * Phase 2's live-HF→RankerCandidate parser was never built, so autonomous
15317
+ * swap-proposal discovery is out of scope here (flagged concern). The tick
15318
+ * still performs F10 drift reconciliation, O1 logging, and re-ranks/dedups
15319
+ * whatever candidates are supplied — the wiring is complete and candidate
15320
+ * breadth is the only piece deferred to the Phase 2 recommender.
15321
+ */
15322
+ startRefreshScheduler() {
15323
+ if (this.modelPool === null) return;
15324
+ const pool = this.modelPool;
15325
+ const refreshCfg = this.config.localModels?.refresh;
15326
+ const frozen = (0, import_local_models4.loadFrozenCandidates)();
15327
+ const candidates = (0, import_local_models4.selectCandidates)(frozen.candidates, this.config.localModels?.pool);
15328
+ for (const warning of frozen.warnings) {
15329
+ this.logger.warn("LMLM frozen candidate snapshot degraded", { warning });
15330
+ }
15331
+ if (candidates.length === 0) {
15332
+ this.logger.warn("LMLM recommender has no candidates", {
15333
+ frozenCount: frozen.candidates.length,
15334
+ allowedOrgs: this.config.localModels?.pool?.allowedOrgs ?? []
15335
+ });
15336
+ }
15337
+ const recommend = (0, import_local_models4.createNativeRecommender)({ candidates });
15338
+ this.modelRecommender = recommend;
15339
+ this.refreshScheduler = new import_local_models4.RefreshScheduler({
15340
+ runTick: () => (0, import_local_models4.runRefreshTick)({
15341
+ detectHardware: () => this.detectLmlmHardware(),
15342
+ recommend,
15343
+ poolManager: pool,
15344
+ dedupSource: () => this.lmlmDedupSource(),
15345
+ // Phase 7: after persisting the proposal, emit `local-models:proposal`
15346
+ // (== MODEL_PROPOSAL_TOPIC) on the bus so it fans out to WS clients and
15347
+ // notification sinks. Literal to avoid a proposals/model-handlers cycle.
15348
+ emitProposal: (c) => (0, import_core18.createModelProposal)(this.projectRoot, c).then((record) => {
15349
+ this.emit("local-models:proposal", {
15350
+ id: record.id,
15351
+ status: "created",
15352
+ action: c.action,
15353
+ target: c.target.ollamaName
15354
+ });
15355
+ }),
15356
+ proposalThreshold: refreshCfg?.proposalThreshold ?? 5
15357
+ }).then((result) => {
15358
+ void this.drainDeferredEvictions();
15359
+ return result;
15360
+ }),
15361
+ intervalMs: refreshCfg?.intervalMs ?? 864e5,
15362
+ jitterMs: refreshCfg?.jitterMs ?? 6e5,
15363
+ logger: this.logger,
15364
+ ...this.schedulerTimerOverride ?? {}
15365
+ });
15366
+ this.refreshScheduler.start();
15367
+ }
15368
+ /** Resolve the hardware profile for a refresh tick (operator override wins). */
15369
+ async detectLmlmHardware() {
15370
+ const override = this.config.localModels?.hardware?.override;
15371
+ const detector = new import_local_models4.HardwareDetector(override !== void 0 ? { override } : {});
15372
+ return (await detector.detect()).profile;
15373
+ }
15374
+ /** Map the on-disk model-proposal queue to F7 dedup pairs (open→pending, rejected→rejected). */
15375
+ async lmlmDedupSource() {
15376
+ const proposals = await (0, import_core18.listProposals)(this.projectRoot, { kind: "model" });
15377
+ const pending = [];
15378
+ const rejected = [];
15379
+ for (const p of proposals) {
15380
+ if (p.kind !== "model") continue;
15381
+ const pair = {
15382
+ target: p.model.target.ollamaName,
15383
+ ...p.model.replaces ? { replaces: p.model.replaces.ollamaName } : {}
15384
+ };
15385
+ if (p.status === "open") pending.push(pair);
15386
+ else if (p.status === "rejected") rejected.push(pair);
15387
+ }
15388
+ return { pending, rejected };
15389
+ }
14450
15390
  async initLocalModelAndPipeline() {
14451
15391
  if (this.localResolvers.size > 0) {
14452
15392
  const backends = this.config.agent.backends ?? {};
@@ -14469,10 +15409,15 @@ ${messages}`);
14469
15409
  });
14470
15410
  this.localModelStatusUnsubscribes.push(unsubscribe);
14471
15411
  }
15412
+ if (this.poolStateStore !== null) {
15413
+ await this.poolStateStore.load();
15414
+ await this.applyConfiguredPoolBounds();
15415
+ }
14472
15416
  for (const resolver of this.localResolvers.values()) {
14473
15417
  await resolver.start();
14474
15418
  }
14475
15419
  }
15420
+ this.startRefreshScheduler();
14476
15421
  this.pipeline = this.createIntelligencePipeline();
14477
15422
  this.server?.setPipeline(this.pipeline);
14478
15423
  }
@@ -14548,6 +15493,8 @@ ${messages}`);
14548
15493
  for (const resolver of this.localResolvers.values()) {
14549
15494
  resolver.stop();
14550
15495
  }
15496
+ this.refreshScheduler?.stop();
15497
+ this.refreshScheduler = null;
14551
15498
  if (this.maintenanceScheduler) {
14552
15499
  this.maintenanceScheduler.stop();
14553
15500
  this.maintenanceScheduler = null;