@harness-engineering/orchestrator 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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_core17 = 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"]);
@@ -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,159 @@ 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
+
8815
9189
  // src/server/routes/v1/routing.ts
8816
9190
  var import_zod14 = require("zod");
8817
9191
  var CONFIG_RE = /^\/api\/v1\/routing\/config(?:\?.*)?$/;
8818
9192
  var DECISIONS_RE = /^\/api\/v1\/routing\/decisions(?:\?.*)?$/;
8819
9193
  var TRACE_RE = /^\/api\/v1\/routing\/trace(?:\?.*)?$/;
8820
- function sendJSON9(res, status, body) {
9194
+ function sendJSON10(res, status, body) {
8821
9195
  res.writeHead(status, { "Content-Type": "application/json" });
8822
9196
  res.end(JSON.stringify(body));
8823
9197
  }
8824
9198
  function unavailable(res) {
8825
- sendJSON9(res, 503, { error: "BackendRouter not available" });
9199
+ sendJSON10(res, 503, { error: "BackendRouter not available" });
8826
9200
  return true;
8827
9201
  }
8828
9202
  function resolveChain(value, backends) {
@@ -8859,7 +9233,7 @@ function buildResolvedChains(routing, backends) {
8859
9233
  }
8860
9234
  function handleConfig(res, deps) {
8861
9235
  if (!deps.router || !deps.routing || !deps.backends) return unavailable(res);
8862
- sendJSON9(res, 200, {
9236
+ sendJSON10(res, 200, {
8863
9237
  routing: deps.routing,
8864
9238
  resolvedChains: buildResolvedChains(deps.routing, deps.backends),
8865
9239
  backends: Object.keys(deps.backends)
@@ -8887,7 +9261,7 @@ function parseDecisionsQuery(url) {
8887
9261
  function handleDecisions(req, res, deps) {
8888
9262
  if (!deps.bus) return unavailable(res);
8889
9263
  const filter = parseDecisionsQuery(req.url ?? "");
8890
- sendJSON9(res, 200, { decisions: deps.bus.recent(filter) });
9264
+ sendJSON10(res, 200, { decisions: deps.bus.recent(filter) });
8891
9265
  return true;
8892
9266
  }
8893
9267
  var UseCaseSchema = import_zod14.z.discriminatedUnion("kind", [
@@ -8919,19 +9293,19 @@ async function handleTrace(req, res, deps) {
8919
9293
  try {
8920
9294
  raw = await readBody(req);
8921
9295
  } catch {
8922
- sendJSON9(res, 400, { error: "body read failed" });
9296
+ sendJSON10(res, 400, { error: "body read failed" });
8923
9297
  return true;
8924
9298
  }
8925
9299
  let parsed;
8926
9300
  try {
8927
9301
  parsed = JSON.parse(raw);
8928
9302
  } catch {
8929
- sendJSON9(res, 400, { error: "invalid JSON body" });
9303
+ sendJSON10(res, 400, { error: "invalid JSON body" });
8930
9304
  return true;
8931
9305
  }
8932
9306
  const r = TraceBodySchema.safeParse(parsed);
8933
9307
  if (!r.success) {
8934
- sendJSON9(res, 400, { error: r.error.message });
9308
+ sendJSON10(res, 400, { error: r.error.message });
8935
9309
  return true;
8936
9310
  }
8937
9311
  const opts = r.data.invocationOverride !== void 0 ? { invocationOverride: r.data.invocationOverride } : void 0;
@@ -8944,9 +9318,9 @@ async function handleTrace(req, res, deps) {
8944
9318
  r.data.useCase,
8945
9319
  opts
8946
9320
  );
8947
- sendJSON9(res, 200, { decision, def: { type: def.type } });
9321
+ sendJSON10(res, 200, { decision, def: { type: def.type } });
8948
9322
  } catch (err) {
8949
- sendJSON9(res, 500, { error: String(err) });
9323
+ sendJSON10(res, 500, { error: String(err) });
8950
9324
  }
8951
9325
  return true;
8952
9326
  }
@@ -9183,7 +9557,7 @@ var CreateBodySchema = import_zod16.z.object({
9183
9557
  tenantId: import_zod16.z.string().optional(),
9184
9558
  expiresAt: import_zod16.z.string().datetime().optional()
9185
9559
  });
9186
- function sendJSON10(res, status, body) {
9560
+ function sendJSON11(res, status, body) {
9187
9561
  res.writeHead(status, { "Content-Type": "application/json" });
9188
9562
  res.end(JSON.stringify(body));
9189
9563
  }
@@ -9193,19 +9567,19 @@ async function handlePost(req, res, store) {
9193
9567
  raw = await readBody(req);
9194
9568
  } catch (err) {
9195
9569
  const msg = err instanceof Error ? err.message : "Failed to read body";
9196
- sendJSON10(res, 413, { error: msg });
9570
+ sendJSON11(res, 413, { error: msg });
9197
9571
  return;
9198
9572
  }
9199
9573
  let json;
9200
9574
  try {
9201
9575
  json = JSON.parse(raw);
9202
9576
  } catch {
9203
- sendJSON10(res, 400, { error: "Invalid JSON body" });
9577
+ sendJSON11(res, 400, { error: "Invalid JSON body" });
9204
9578
  return;
9205
9579
  }
9206
9580
  const parsed = CreateBodySchema.safeParse(json);
9207
9581
  if (!parsed.success) {
9208
- sendJSON10(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9582
+ sendJSON11(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9209
9583
  return;
9210
9584
  }
9211
9585
  try {
@@ -9218,37 +9592,37 @@ async function handlePost(req, res, store) {
9218
9592
  if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
9219
9593
  const result = await store.create(input);
9220
9594
  const publicRecord = import_types25.AuthTokenPublicSchema.parse(result.record);
9221
- sendJSON10(res, 200, {
9595
+ sendJSON11(res, 200, {
9222
9596
  ...publicRecord,
9223
9597
  token: result.token
9224
9598
  });
9225
9599
  } catch (err) {
9226
9600
  const msg = err instanceof Error ? err.message : "Failed to create token";
9227
9601
  if (msg.includes("already exists")) {
9228
- sendJSON10(res, 409, { error: msg });
9602
+ sendJSON11(res, 409, { error: msg });
9229
9603
  return;
9230
9604
  }
9231
- sendJSON10(res, 500, { error: "Internal error creating token" });
9605
+ sendJSON11(res, 500, { error: "Internal error creating token" });
9232
9606
  }
9233
9607
  }
9234
9608
  async function handleList3(res, store) {
9235
9609
  try {
9236
9610
  const list = await store.list();
9237
- sendJSON10(res, 200, list);
9611
+ sendJSON11(res, 200, list);
9238
9612
  } catch {
9239
- sendJSON10(res, 500, { error: "Internal error listing tokens" });
9613
+ sendJSON11(res, 500, { error: "Internal error listing tokens" });
9240
9614
  }
9241
9615
  }
9242
9616
  async function handleDelete2(res, store, id) {
9243
9617
  try {
9244
9618
  const ok = await store.revoke(id);
9245
9619
  if (!ok) {
9246
- sendJSON10(res, 404, { error: "Token not found" });
9620
+ sendJSON11(res, 404, { error: "Token not found" });
9247
9621
  return;
9248
9622
  }
9249
- sendJSON10(res, 200, { deleted: true });
9623
+ sendJSON11(res, 200, { deleted: true });
9250
9624
  } catch {
9251
- sendJSON10(res, 500, { error: "Internal error revoking token" });
9625
+ sendJSON11(res, 500, { error: "Internal error revoking token" });
9252
9626
  }
9253
9627
  }
9254
9628
  var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
@@ -9273,12 +9647,12 @@ function handleAuthRoute(req, res, store) {
9273
9647
  return true;
9274
9648
  }
9275
9649
  }
9276
- sendJSON10(res, 405, { error: "Method not allowed" });
9650
+ sendJSON11(res, 405, { error: "Method not allowed" });
9277
9651
  return true;
9278
9652
  }
9279
9653
 
9280
9654
  // src/server/routes/local-model.ts
9281
- function sendJSON11(res, status, body) {
9655
+ function sendJSON12(res, status, body) {
9282
9656
  res.writeHead(status, { "Content-Type": "application/json" });
9283
9657
  res.end(JSON.stringify(body));
9284
9658
  }
@@ -9286,30 +9660,30 @@ function handleLocalModelRoute(req, res, getStatus) {
9286
9660
  const { method, url } = req;
9287
9661
  if (url !== "/api/v1/local-model/status") return false;
9288
9662
  if (method !== "GET") {
9289
- sendJSON11(res, 405, { error: "Method not allowed" });
9663
+ sendJSON12(res, 405, { error: "Method not allowed" });
9290
9664
  return true;
9291
9665
  }
9292
9666
  if (!getStatus) {
9293
- sendJSON11(res, 503, { error: "Local backend not configured" });
9667
+ sendJSON12(res, 503, { error: "Local backend not configured" });
9294
9668
  return true;
9295
9669
  }
9296
9670
  const status = getStatus();
9297
9671
  if (!status) {
9298
- sendJSON11(res, 503, { error: "Local backend not configured" });
9672
+ sendJSON12(res, 503, { error: "Local backend not configured" });
9299
9673
  return true;
9300
9674
  }
9301
- sendJSON11(res, 200, status);
9675
+ sendJSON12(res, 200, status);
9302
9676
  return true;
9303
9677
  }
9304
9678
  function handleLocalModelsRoute(req, res, getStatuses) {
9305
9679
  const { method, url } = req;
9306
9680
  if (url !== "/api/v1/local-models/status") return false;
9307
9681
  if (method !== "GET") {
9308
- sendJSON11(res, 405, { error: "Method not allowed" });
9682
+ sendJSON12(res, 405, { error: "Method not allowed" });
9309
9683
  return true;
9310
9684
  }
9311
9685
  const statuses = getStatuses ? getStatuses() : [];
9312
- sendJSON11(res, 200, statuses);
9686
+ sendJSON12(res, 200, statuses);
9313
9687
  return true;
9314
9688
  }
9315
9689
 
@@ -9673,6 +10047,45 @@ var V1_BRIDGE_ROUTES = [
9673
10047
  scope: "read-telemetry",
9674
10048
  description: "Prompt-cache hit/miss snapshot (rolling window)."
9675
10049
  },
10050
+ // ── LMLM Phase 6 — force-refresh (background scheduler tick, out of band) ──
10051
+ // Reuses `manage-proposals`: a forced refresh emits model proposals, so the
10052
+ // same write scope that governs approve/reject governs triggering the tick.
10053
+ {
10054
+ method: "POST",
10055
+ pattern: /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/,
10056
+ scope: "manage-proposals",
10057
+ description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
10058
+ },
10059
+ // ── LMLM Phase 7 — read surface (hardware/pool/recommendations/proposals) ──
10060
+ // Load-bearing: `local-models` is in `V1_WRAPPABLE`, so without these entries
10061
+ // the `/api/v1` rewrite shim would rewrite `/api/v1/local-models/<name>` →
10062
+ // `/api/local-models/<name>` and misroute it to the legacy status handler.
10063
+ // `isV1Bridge` short-circuits the rewrite; `requiredBridgeScope` supplies the
10064
+ // default-deny read scope. All four are read-only observability (`read-status`).
10065
+ {
10066
+ method: "GET",
10067
+ pattern: /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/,
10068
+ scope: "read-status",
10069
+ description: "Current detected HardwareProfile (RAM/VRAM/GPU)."
10070
+ },
10071
+ {
10072
+ method: "GET",
10073
+ pattern: /^\/api\/v1\/local-models\/pool(?:\?.*)?$/,
10074
+ scope: "read-status",
10075
+ description: "Live PoolState view, including transient pendingEviction flags."
10076
+ },
10077
+ {
10078
+ method: "GET",
10079
+ pattern: /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/,
10080
+ scope: "read-status",
10081
+ description: "Hardware-ranked model recommendations (top/profile query params)."
10082
+ },
10083
+ {
10084
+ method: "GET",
10085
+ pattern: /^\/api\/v1\/local-models\/proposals(?:\?.*)?$/,
10086
+ scope: "read-status",
10087
+ description: "Open model-kind proposals (pending review queue)."
10088
+ },
9676
10089
  // ── Spec B Phase 5 routing observability ──
9677
10090
  // D-OP-1: all three reuse `read-telemetry` — matches the cacheMetrics
9678
10091
  // precedent (read-only observability). A dedicated `read-routing`
@@ -9815,7 +10228,19 @@ var OrchestratorServer = class {
9815
10228
  getRoutingDecisionBusFn = null;
9816
10229
  getRoutingConfigFn = null;
9817
10230
  getBackendsFn = null;
10231
+ // LMLM Phase 6 — live model pool + refresh scheduler accessors.
10232
+ getModelPoolFn = null;
10233
+ getRefreshSchedulerFn = null;
10234
+ // LMLM Phase 7 — hardware / recommendations / model-proposal read accessors.
10235
+ getHardwareProfileFn = null;
10236
+ getRecommendationsFn = null;
10237
+ listModelProposalsFn = null;
10238
+ // LMLM Phase 7 / S1 — in-use probe threaded into kind:'model' approve.
10239
+ isModelInUseFn = null;
9818
10240
  routingDecisionUnsubscribe = null;
10241
+ // LMLM Phase 7 — bus→WS fan-out listeners for the model proposal/pool topics.
10242
+ modelProposalListener = null;
10243
+ modelPoolListener = null;
9819
10244
  recorder = null;
9820
10245
  planWatcher = null;
9821
10246
  tokenStore;
@@ -9860,6 +10285,12 @@ var OrchestratorServer = class {
9860
10285
  this.getRoutingDecisionBusFn = deps?.getRoutingDecisionBus ?? null;
9861
10286
  this.getRoutingConfigFn = deps?.getRoutingConfig ?? null;
9862
10287
  this.getBackendsFn = deps?.getBackends ?? null;
10288
+ this.getModelPoolFn = deps?.getModelPool ?? null;
10289
+ this.getRefreshSchedulerFn = deps?.getRefreshScheduler ?? null;
10290
+ this.getHardwareProfileFn = deps?.getHardwareProfile ?? null;
10291
+ this.getRecommendationsFn = deps?.getRecommendations ?? null;
10292
+ this.listModelProposalsFn = deps?.listModelProposals ?? null;
10293
+ this.isModelInUseFn = deps?.isModelInUse ?? null;
9863
10294
  }
9864
10295
  wireEvents() {
9865
10296
  this.stateChangeListener = (snapshot) => {
@@ -9876,6 +10307,10 @@ var OrchestratorServer = class {
9876
10307
  this.broadcaster.broadcast("routing:decision", decision);
9877
10308
  });
9878
10309
  }
10310
+ this.modelProposalListener = (data) => this.broadcaster.broadcast(MODEL_PROPOSAL_TOPIC, data);
10311
+ this.modelPoolListener = (data) => this.broadcaster.broadcast(MODEL_POOL_TOPIC, data);
10312
+ this.orchestrator.on(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
10313
+ this.orchestrator.on(MODEL_POOL_TOPIC, this.modelPoolListener);
9879
10314
  }
9880
10315
  /**
9881
10316
  * Broadcast a new interaction to all WebSocket clients.
@@ -10044,9 +10479,27 @@ var OrchestratorServer = class {
10044
10479
  // upstream by V1_BRIDGE_ROUTES; this dispatcher only handles
10045
10480
  // business logic. `projectPath` defaults to process.cwd() — that is
10046
10481
  // where `.harness/proposals/` lives in every deployment we ship.
10047
- (req, res) => handleV1ProposalsRoute(req, res, {
10048
- projectPath: this.projectPath,
10049
- bus: this.orchestrator
10482
+ (req, res) => {
10483
+ const modelPool = this.getModelPoolFn?.() ?? null;
10484
+ return handleV1ProposalsRoute(req, res, {
10485
+ projectPath: this.projectPath,
10486
+ bus: this.orchestrator,
10487
+ ...modelPool ? { modelPool } : {},
10488
+ // S1: thread the in-use probe so an approved swap/evict of a model an
10489
+ // agent could be using is deferred (marked pendingEviction) not applied.
10490
+ ...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
10491
+ });
10492
+ },
10493
+ // LMLM Phase 6/7 — POST /refresh + the GET read surface
10494
+ // (hardware/pool/recommendations/proposals). Registered before the
10495
+ // chat-proxy fallback so it owns the path. Each accessor is spread in
10496
+ // only when configured so absent ones surface as 503 (LMLM disabled).
10497
+ (req, res) => handleV1LocalModelsRoute(req, res, {
10498
+ getRefreshScheduler: this.getRefreshSchedulerFn ?? (() => null),
10499
+ getModelPool: () => this.getModelPoolFn?.() ?? null,
10500
+ ...this.getHardwareProfileFn ? { getHardwareProfile: this.getHardwareProfileFn } : {},
10501
+ ...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
10502
+ ...this.listModelProposalsFn ? { listModelProposals: this.listModelProposalsFn } : {}
10050
10503
  }),
10051
10504
  // Chat proxy route (spawns Claude Code CLI — no API key required)
10052
10505
  (req, res) => handleChatProxyRoute(req, res, this.claudeCommand)
@@ -10150,6 +10603,14 @@ var OrchestratorServer = class {
10150
10603
  this.routingDecisionUnsubscribe();
10151
10604
  this.routingDecisionUnsubscribe = null;
10152
10605
  }
10606
+ if (this.modelProposalListener) {
10607
+ this.orchestrator.removeListener(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
10608
+ this.modelProposalListener = null;
10609
+ }
10610
+ if (this.modelPoolListener) {
10611
+ this.orchestrator.removeListener(MODEL_POOL_TOPIC, this.modelPoolListener);
10612
+ this.modelPoolListener = null;
10613
+ }
10153
10614
  if (this.planWatcher) {
10154
10615
  this.planWatcher.stop();
10155
10616
  this.planWatcher = null;
@@ -11065,8 +11526,50 @@ var ENVELOPE_DERIVERS = {
11065
11526
  summary: truncate(data.reason ?? "No reason provided.", 240),
11066
11527
  severity: "warning"
11067
11528
  };
11068
- }
11529
+ },
11530
+ // LMLM Phase 7 — model-proposal lifecycle. Delegated to a status-keyed
11531
+ // lookup (below) so each status branch is its own small function and the
11532
+ // deriver stays under the cyclomatic-complexity gate.
11533
+ "local-models.proposal": (event) => deriveModelProposalEnvelope(event)
11069
11534
  };
11535
+ var MODEL_PROPOSAL_ENVELOPES = {
11536
+ created: (data) => {
11537
+ const label = `${data.action ?? "change"} ${data.target ?? "(unknown model)"}`;
11538
+ return {
11539
+ title: `New model proposal: ${label}`,
11540
+ summary: `A model proposal (${label}) is awaiting review.`,
11541
+ severity: "info"
11542
+ };
11543
+ },
11544
+ approved: (data) => {
11545
+ const label = `${data.action ?? "change"} ${data.target ?? "(unknown model)"}`;
11546
+ return {
11547
+ title: `Model proposal approved: ${label}`,
11548
+ summary: `The model proposal (${label}) was approved and applied to the pool.`,
11549
+ severity: "success"
11550
+ };
11551
+ },
11552
+ rejected: (data) => ({
11553
+ title: "Model proposal rejected",
11554
+ summary: truncate(data.reason ?? "No reason provided.", 240),
11555
+ severity: "warning"
11556
+ }),
11557
+ failed_target_missing: (data) => ({
11558
+ title: "Model proposal target missing",
11559
+ summary: `The proposed model ${data.target ?? "(unknown model)"} is no longer available; the proposal was closed.`,
11560
+ severity: "error"
11561
+ })
11562
+ };
11563
+ function deriveModelProposalEnvelope(event) {
11564
+ const data = asObj(event.data);
11565
+ const build = MODEL_PROPOSAL_ENVELOPES[data.status ?? ""];
11566
+ if (build) return build(data);
11567
+ return {
11568
+ title: `Model proposal update: ${data.target ?? "(unknown model)"}`,
11569
+ summary: `Model proposal status: ${data.status ?? "(unknown)"}.`,
11570
+ severity: "info"
11571
+ };
11572
+ }
11070
11573
  function truncate(s, max) {
11071
11574
  return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
11072
11575
  }
@@ -11113,7 +11616,9 @@ var NOTIFICATION_TOPICS = [
11113
11616
  // Hermes Phase 4 — skill proposal lifecycle.
11114
11617
  "proposal.created",
11115
11618
  "proposal.approved",
11116
- "proposal.rejected"
11619
+ "proposal.rejected",
11620
+ // LMLM Phase 7 — model-proposal lifecycle (colon→dot ⇒ 'local-models.proposal').
11621
+ "local-models:proposal"
11117
11622
  ];
11118
11623
  function newEventId3() {
11119
11624
  return `evt_${(0, import_node_crypto14.randomBytes)(8).toString("hex")}`;
@@ -11169,7 +11674,7 @@ function wireNotificationSinks({ bus, registry }) {
11169
11674
  }
11170
11675
 
11171
11676
  // src/orchestrator.ts
11172
- var import_core17 = require("@harness-engineering/core");
11677
+ var import_core18 = require("@harness-engineering/core");
11173
11678
 
11174
11679
  // src/logging/logger.ts
11175
11680
  var StructuredLogger = class {
@@ -13198,6 +13703,45 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13198
13703
  * so this map is the single source of truth post-migration.
13199
13704
  */
13200
13705
  localResolvers = /* @__PURE__ */ new Map();
13706
+ /** Phase 4 (D5): pool-state port shared by all local/pi resolvers. Null when LMLM disabled. */
13707
+ poolStateProvider = null;
13708
+ poolStateStore = null;
13709
+ /**
13710
+ * LMLM Phase 6: live model pool + its installer. Constructed only when
13711
+ * `localModels.enabled` and a real `PoolStateStore` exists (not a test
13712
+ * override). Exposed to the server via `getModelPool()`, which retires the
13713
+ * proposals-route 501 stub for `kind: 'model'` approve/reject. Null when LMLM
13714
+ * is disabled. `PoolManager` reads `store.snapshot()` lazily, so constructing
13715
+ * before `store.load()` (in initLocalModelAndPipeline) is safe.
13716
+ */
13717
+ modelPool = null;
13718
+ modelInstaller = null;
13719
+ /**
13720
+ * S1 drain re-entrancy guard (P7-SUG-DRAIN-REENTRANCY). `drainDeferredEvictions`
13721
+ * is fired fire-and-forget from `emitWorkerExit` (and, since P7-SUG-DRAIN-LIVENESS,
13722
+ * piggybacked on each refresh tick). Two overlapping drains would both read the
13723
+ * same `listPendingEvictions()` snapshot, both re-check `isLocalModelInUse`, and
13724
+ * both `await pool.evict` the SAME name — double-calling the installer and
13725
+ * broadcasting a duplicate `evict_completed` frame. The single-threaded event
13726
+ * loop makes a plain boolean sufficient: a drain that arrives while one is
13727
+ * running returns early rather than double-processing.
13728
+ */
13729
+ draining = false;
13730
+ /**
13731
+ * LMLM Phase 6: single per-instance background refresh scheduler. Started in
13732
+ * `initLocalModelAndPipeline` when the pool exists; stopped in `stop()`. Null
13733
+ * when LMLM is disabled. Exposed to the server via `getRefreshScheduler()`.
13734
+ */
13735
+ refreshScheduler = null;
13736
+ /**
13737
+ * LMLM Phase 7: the hardware-aware recommender bound at scheduler start. Reused
13738
+ * by `GET /api/v1/local-models/recommendations`. Null when LMLM is disabled (no
13739
+ * pool → scheduler never armed). Ranks the (currently empty) candidate set —
13740
+ * see the Phase 2 candidate-parser gap noted on `startRefreshScheduler`.
13741
+ */
13742
+ modelRecommender = null;
13743
+ /** Test seam: injected timer/clock for the scheduler so no real 24h timer runs. */
13744
+ schedulerTimerOverride;
13201
13745
  /**
13202
13746
  * Spec B Phase 3: skill catalog (name + cognitiveMode) read once at
13203
13747
  * construction from `projectRoot/agents/skills/`. Consulted by
@@ -13288,6 +13832,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13288
13832
  */
13289
13833
  constructor(config, promptTemplate, overrides) {
13290
13834
  super();
13835
+ this.schedulerTimerOverride = overrides?.schedulerTimer ?? null;
13291
13836
  this.setMaxListeners(50);
13292
13837
  this.config = config;
13293
13838
  this.promptTemplate = promptTemplate;
@@ -13335,6 +13880,16 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13335
13880
  this
13336
13881
  );
13337
13882
  this.analysisArchive = new AnalysisArchive(path21.join(config.workspace.root, "..", "analyses"));
13883
+ const localModelsEnabled = this.config.localModels?.enabled === true;
13884
+ if (overrides?.poolState) {
13885
+ this.poolStateProvider = overrides.poolState;
13886
+ } else if (localModelsEnabled) {
13887
+ this.poolStateStore = new import_local_models4.PoolStateStore({
13888
+ onWarn: (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0)
13889
+ });
13890
+ this.poolStateProvider = this.poolStateStore;
13891
+ this.initModelPool(this.poolStateStore);
13892
+ }
13338
13893
  const backendsMap = this.config.agent.backends ?? {};
13339
13894
  for (const [name, def] of Object.entries(backendsMap)) {
13340
13895
  if (def.type === "local" || def.type === "pi") {
@@ -13345,10 +13900,11 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13345
13900
  };
13346
13901
  if (def.apiKey !== void 0) resolverOpts.apiKey = def.apiKey;
13347
13902
  if (def.probeIntervalMs !== void 0) resolverOpts.probeIntervalMs = def.probeIntervalMs;
13903
+ if (this.poolStateProvider !== null) resolverOpts.poolState = this.poolStateProvider;
13348
13904
  this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
13349
13905
  }
13350
13906
  }
13351
- this.cacheMetrics = new import_core17.CacheMetricsRecorder();
13907
+ this.cacheMetrics = new import_core18.CacheMetricsRecorder();
13352
13908
  if (this.config.agent.backends !== void 0 && Object.keys(this.config.agent.backends).length > 0) {
13353
13909
  const sandboxPolicy = this.config.agent.sandboxPolicy === "docker" ? "docker" : "none";
13354
13910
  const firstBackendName = Object.keys(this.config.agent.backends)[0];
@@ -13456,7 +14012,26 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13456
14012
  roadmapPath: config.tracker.filePath ?? null,
13457
14013
  dispatchAdHoc: this.dispatchAdHoc.bind(this),
13458
14014
  getLocalModelStatus: () => this.getFirstLocalModelStatus(),
13459
- getLocalModelStatuses: () => this.buildLocalModelStatuses()
14015
+ getLocalModelStatuses: () => this.buildLocalModelStatuses(),
14016
+ // LMLM Phase 6: expose the live pool so kind:'model' approve/reject
14017
+ // reaches PoolManager (retiring the 501). Null when LMLM is disabled.
14018
+ getModelPool: () => this.modelPool,
14019
+ // LMLM Phase 7 / S1: conservative in-use probe so an approved swap/evict
14020
+ // of a model an agent could be using is DEFERRED, not applied mid-request
14021
+ // (ADR 0060). Agent-run-coarse; may over-defer (safe).
14022
+ isModelInUse: (ollamaName) => this.isLocalModelInUse(ollamaName),
14023
+ // LMLM Phase 6: expose the refresh scheduler for POST /local-models/refresh.
14024
+ getRefreshScheduler: () => this.refreshScheduler,
14025
+ // LMLM Phase 7 read surface — hardware / recommendations / model proposals.
14026
+ // Each returns null/[] when LMLM is disabled so the route renders 503/[].
14027
+ getHardwareProfile: () => this.modelPool ? this.detectLmlmHardware() : null,
14028
+ getRecommendations: async ({ top }) => {
14029
+ if (this.modelRecommender === null) return [];
14030
+ const hardware = await this.detectLmlmHardware();
14031
+ const { ranked } = await this.modelRecommender(hardware);
14032
+ return ranked.slice(0, top);
14033
+ },
14034
+ listModelProposals: () => (0, import_core17.listProposals)(this.projectRoot, { status: "open", kind: "model" })
13460
14035
  });
13461
14036
  this.server.setRecorder(this.recorder);
13462
14037
  this.interactionQueue.onPush((interaction) => {
@@ -13473,7 +14048,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13473
14048
  setupTelemetryExport(config, webhookStore, webhookDelivery) {
13474
14049
  const otlpCfg = config.telemetry?.export?.otlp;
13475
14050
  if (!otlpCfg) return;
13476
- this.otlpExporter = new import_core17.OTLPExporter({
14051
+ this.otlpExporter = new import_core18.OTLPExporter({
13477
14052
  endpoint: otlpCfg.endpoint,
13478
14053
  ...otlpCfg.enabled !== void 0 ? { enabled: otlpCfg.enabled } : {},
13479
14054
  ...otlpCfg.headers !== void 0 ? { headers: otlpCfg.headers } : {},
@@ -13514,6 +14089,60 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13514
14089
  }
13515
14090
  return out;
13516
14091
  }
14092
+ /**
14093
+ * S1 conservative in-use probe (ADR 0060). Returns `true` when ANY agent run
14094
+ * is live AND `ollamaName` is a currently-resolved (or last-detected) local
14095
+ * model — i.e. an agent could be routing inference to it right now.
14096
+ *
14097
+ * This signal is AGENT-RUN-COARSE, not per-request: `state.running` is keyed
14098
+ * by GitHub issue (spawned agent runs), NOT by inference call, and no
14099
+ * per-model request counter exists today. The probe therefore MAY OVER-DEFER
14100
+ * (a swap waits until the pool is idle). Over-deferral is exactly S1's
14101
+ * intended safe failure — never yank a model mid-request; occasionally wait
14102
+ * longer than strictly necessary. A fine-grained per-request signal is an
14103
+ * explicit deferred gap (ADR 0060).
14104
+ */
14105
+ isLocalModelInUse(ollamaName) {
14106
+ if (this.state.running.size === 0) return false;
14107
+ return this.buildLocalModelStatuses().some(
14108
+ (s) => s.resolved === ollamaName || s.detected.includes(ollamaName)
14109
+ );
14110
+ }
14111
+ /**
14112
+ * S1 drain (ADR 0060): complete any eviction that was DEFERRED because its
14113
+ * target was in use, now that the probe reports it idle. Best-effort — called
14114
+ * from the run-completion path; it never blocks dispatch and swallows
14115
+ * per-model errors (a failed or still-busy evict stays pending for the next
14116
+ * drain). `pendingEviction` is a transient overlay, so a missed drain simply
14117
+ * leaves the flag set until the next completion re-checks it.
14118
+ */
14119
+ async drainDeferredEvictions() {
14120
+ const pool = this.modelPool;
14121
+ if (pool === null) return;
14122
+ if (this.draining) return;
14123
+ this.draining = true;
14124
+ try {
14125
+ for (const ollamaName of pool.listPendingEvictions()) {
14126
+ if (this.isLocalModelInUse(ollamaName)) continue;
14127
+ try {
14128
+ const result = await pool.evict({ ollamaName });
14129
+ if (result.status === "error") continue;
14130
+ pool.clearPendingEviction(ollamaName);
14131
+ this.emit("local-models:pool", {
14132
+ action: "evict",
14133
+ // XP-2: `evicted` is uniformly string[] across all local-models:pool
14134
+ // emit sites — the drain wraps its single completed eviction in an
14135
+ // array to match the swap/add multi-evict shape.
14136
+ evicted: [ollamaName],
14137
+ phase: "evict_completed"
14138
+ });
14139
+ } catch {
14140
+ }
14141
+ }
14142
+ } finally {
14143
+ this.draining = false;
14144
+ }
14145
+ }
13517
14146
  createTracker() {
13518
14147
  if (this.config.tracker.kind === "github-issues") {
13519
14148
  const trackerCfg = {
@@ -14360,6 +14989,7 @@ ${messages}`);
14360
14989
  error,
14361
14990
  (effect) => this.handleEffect(effect)
14362
14991
  );
14992
+ void this.drainDeferredEvictions();
14363
14993
  this.emit("state_change", this.getSnapshot());
14364
14994
  }
14365
14995
  /**
@@ -14447,6 +15077,91 @@ ${messages}`);
14447
15077
  * before constructing the intelligence pipeline. Subscribes the dashboard
14448
15078
  * broadcast stub to status changes. Called exactly once from start().
14449
15079
  */
15080
+ /**
15081
+ * LMLM Phase 6: construct the live `PoolManager` (Ollama installer + the
15082
+ * loaded pool-state store) and stash it for `getModelPool()`. Defensive
15083
+ * config fallbacks: `ollamaEndpoint → http://localhost:11434`. The pool reads
15084
+ * `store.snapshot()` lazily, so this runs safely at construction time before
15085
+ * `store.load()`.
15086
+ */
15087
+ initModelPool(store) {
15088
+ const onWarn = (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0);
15089
+ const installerCfg = this.config.localModels?.installer;
15090
+ this.modelInstaller = new import_local_models4.OllamaInstallAdapter({
15091
+ baseUrl: installerCfg?.ollamaEndpoint ?? "http://localhost:11434",
15092
+ onWarn
15093
+ });
15094
+ this.modelPool = new import_local_models4.PoolManager({ store, installer: this.modelInstaller, onWarn });
15095
+ }
15096
+ /**
15097
+ * LMLM Phase 6: arm the single background refresh scheduler over the live
15098
+ * pool. No-op when LMLM is disabled (`modelPool` null). Each tick runs
15099
+ * hardware→recommend→reconcile(D12 drift)→diff→emit→score-writeback.
15100
+ *
15101
+ * NOTE (deferred): the recommender is seeded with an empty candidate set —
15102
+ * Phase 2's live-HF→RankerCandidate parser was never built, so autonomous
15103
+ * swap-proposal discovery is out of scope here (flagged concern). The tick
15104
+ * still performs F10 drift reconciliation, O1 logging, and re-ranks/dedups
15105
+ * whatever candidates are supplied — the wiring is complete and candidate
15106
+ * breadth is the only piece deferred to the Phase 2 recommender.
15107
+ */
15108
+ startRefreshScheduler() {
15109
+ if (this.modelPool === null) return;
15110
+ const pool = this.modelPool;
15111
+ const refreshCfg = this.config.localModels?.refresh;
15112
+ const recommend = (0, import_local_models4.createNativeRecommender)({ candidates: [] });
15113
+ this.modelRecommender = recommend;
15114
+ this.refreshScheduler = new import_local_models4.RefreshScheduler({
15115
+ runTick: () => (0, import_local_models4.runRefreshTick)({
15116
+ detectHardware: () => this.detectLmlmHardware(),
15117
+ recommend,
15118
+ poolManager: pool,
15119
+ dedupSource: () => this.lmlmDedupSource(),
15120
+ // Phase 7: after persisting the proposal, emit `local-models:proposal`
15121
+ // (== MODEL_PROPOSAL_TOPIC) on the bus so it fans out to WS clients and
15122
+ // notification sinks. Literal to avoid a proposals/model-handlers cycle.
15123
+ emitProposal: (c) => (0, import_core17.createModelProposal)(this.projectRoot, c).then((record) => {
15124
+ this.emit("local-models:proposal", {
15125
+ id: record.id,
15126
+ status: "created",
15127
+ action: c.action,
15128
+ target: c.target.ollamaName
15129
+ });
15130
+ }),
15131
+ proposalThreshold: refreshCfg?.proposalThreshold ?? 5
15132
+ }).then((result) => {
15133
+ void this.drainDeferredEvictions();
15134
+ return result;
15135
+ }),
15136
+ intervalMs: refreshCfg?.intervalMs ?? 864e5,
15137
+ jitterMs: refreshCfg?.jitterMs ?? 6e5,
15138
+ logger: this.logger,
15139
+ ...this.schedulerTimerOverride ?? {}
15140
+ });
15141
+ this.refreshScheduler.start();
15142
+ }
15143
+ /** Resolve the hardware profile for a refresh tick (operator override wins). */
15144
+ async detectLmlmHardware() {
15145
+ const override = this.config.localModels?.hardware?.override;
15146
+ const detector = new import_local_models4.HardwareDetector(override !== void 0 ? { override } : {});
15147
+ return (await detector.detect()).profile;
15148
+ }
15149
+ /** Map the on-disk model-proposal queue to F7 dedup pairs (open→pending, rejected→rejected). */
15150
+ async lmlmDedupSource() {
15151
+ const proposals = await (0, import_core17.listProposals)(this.projectRoot, { kind: "model" });
15152
+ const pending = [];
15153
+ const rejected = [];
15154
+ for (const p of proposals) {
15155
+ if (p.kind !== "model") continue;
15156
+ const pair = {
15157
+ target: p.model.target.ollamaName,
15158
+ ...p.model.replaces ? { replaces: p.model.replaces.ollamaName } : {}
15159
+ };
15160
+ if (p.status === "open") pending.push(pair);
15161
+ else if (p.status === "rejected") rejected.push(pair);
15162
+ }
15163
+ return { pending, rejected };
15164
+ }
14450
15165
  async initLocalModelAndPipeline() {
14451
15166
  if (this.localResolvers.size > 0) {
14452
15167
  const backends = this.config.agent.backends ?? {};
@@ -14469,10 +15184,14 @@ ${messages}`);
14469
15184
  });
14470
15185
  this.localModelStatusUnsubscribes.push(unsubscribe);
14471
15186
  }
15187
+ if (this.poolStateStore !== null) {
15188
+ await this.poolStateStore.load();
15189
+ }
14472
15190
  for (const resolver of this.localResolvers.values()) {
14473
15191
  await resolver.start();
14474
15192
  }
14475
15193
  }
15194
+ this.startRefreshScheduler();
14476
15195
  this.pipeline = this.createIntelligencePipeline();
14477
15196
  this.server?.setPipeline(this.pipeline);
14478
15197
  }
@@ -14548,6 +15267,8 @@ ${messages}`);
14548
15267
  for (const resolver of this.localResolvers.values()) {
14549
15268
  resolver.stop();
14550
15269
  }
15270
+ this.refreshScheduler?.stop();
15271
+ this.refreshScheduler = null;
14551
15272
  if (this.maintenanceScheduler) {
14552
15273
  this.maintenanceScheduler.stop();
14553
15274
  this.maintenanceScheduler = null;