@harness-engineering/orchestrator 0.9.1 → 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.mjs CHANGED
@@ -3766,6 +3766,7 @@ var AgentRunner = class {
3766
3766
  };
3767
3767
 
3768
3768
  // src/agent/local-model-resolver.ts
3769
+ import { poolStateToCandidates } from "@harness-engineering/local-models";
3769
3770
  var DEFAULT_PROBE_INTERVAL_MS = 3e4;
3770
3771
  var MIN_PROBE_INTERVAL_MS = 1e3;
3771
3772
  var DEFAULT_API_KEY = "lm-studio";
@@ -3822,6 +3823,7 @@ var LocalModelResolver = class {
3822
3823
  endpoint;
3823
3824
  apiKey;
3824
3825
  configured;
3826
+ poolState;
3825
3827
  probeIntervalMs;
3826
3828
  fetchModels;
3827
3829
  logger;
@@ -3850,6 +3852,9 @@ var LocalModelResolver = class {
3850
3852
  this.apiKey = opts.apiKey;
3851
3853
  }
3852
3854
  this.configured = [...opts.configured];
3855
+ if (opts.poolState !== void 0) {
3856
+ this.poolState = opts.poolState;
3857
+ }
3853
3858
  const interval = opts.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
3854
3859
  this.probeIntervalMs = Math.max(MIN_PROBE_INTERVAL_MS, interval);
3855
3860
  const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
@@ -3863,13 +3868,21 @@ var LocalModelResolver = class {
3863
3868
  return {
3864
3869
  available: this.available,
3865
3870
  resolved: this.resolved,
3866
- configured: [...this.configured],
3871
+ configured: this.candidates(),
3867
3872
  detected: [...this.detected],
3868
3873
  lastProbeAt: this.lastProbeAt,
3869
3874
  lastError: this.lastError,
3870
3875
  warnings: [...this.warnings]
3871
3876
  };
3872
3877
  }
3878
+ /**
3879
+ * Effective candidate list. With a poolState port present the list derives
3880
+ * from pool entries (currentScore desc → ollamaName); otherwise the static
3881
+ * `configured` list is returned unchanged (byte-identical to pre-Phase-4).
3882
+ */
3883
+ candidates() {
3884
+ return this.poolState ? poolStateToCandidates(this.poolState.snapshot()) : [...this.configured];
3885
+ }
3873
3886
  onStatusChange(handler) {
3874
3887
  this.listeners.add(handler);
3875
3888
  return () => {
@@ -3893,11 +3906,12 @@ var LocalModelResolver = class {
3893
3906
  this.detected = [...detected];
3894
3907
  this.lastError = null;
3895
3908
  this.lastProbeAt = (/* @__PURE__ */ new Date()).toISOString();
3896
- const match = this.configured.find((id) => detected.includes(id)) ?? null;
3909
+ const candidates = this.candidates();
3910
+ const match = candidates.find((id) => detected.includes(id)) ?? null;
3897
3911
  this.resolved = match;
3898
3912
  this.available = match !== null;
3899
3913
  this.warnings = match ? [] : [
3900
- `No configured local model is loaded. Configured: [${this.configured.join(", ")}]. Detected: [${detected.join(", ")}].`
3914
+ `No configured local model is loaded. Configured: [${candidates.join(", ")}]. Detected: [${detected.join(", ")}].`
3901
3915
  ];
3902
3916
  } catch (err) {
3903
3917
  const message = err instanceof Error ? err.message : "probe failed";
@@ -3946,7 +3960,7 @@ var LocalModelResolver = class {
3946
3960
  return JSON.stringify({
3947
3961
  available: this.available,
3948
3962
  resolved: this.resolved,
3949
- configured: this.configured,
3963
+ configured: this.candidates(),
3950
3964
  detected: this.detected,
3951
3965
  lastError: this.lastError,
3952
3966
  warnings: this.warnings
@@ -3954,6 +3968,18 @@ var LocalModelResolver = class {
3954
3968
  }
3955
3969
  };
3956
3970
 
3971
+ // src/orchestrator.ts
3972
+ import {
3973
+ PoolStateStore,
3974
+ PoolManager,
3975
+ OllamaInstallAdapter,
3976
+ HardwareDetector,
3977
+ RefreshScheduler,
3978
+ runRefreshTick,
3979
+ createNativeRecommender
3980
+ } from "@harness-engineering/local-models";
3981
+ import { createModelProposal, listProposals as listProposals2 } from "@harness-engineering/core";
3982
+
3957
3983
  // src/agent/config-migration.ts
3958
3984
  var MIGRATION_GUIDE = "docs/guides/multi-backend-routing.md";
3959
3985
  var CASE1_ALWAYS_SUPPRESS = /* @__PURE__ */ new Set(["agent.backend"]);
@@ -8334,10 +8360,10 @@ function checkDiff(diff) {
8334
8360
  function deriveFindings(proposal) {
8335
8361
  const findings = [];
8336
8362
  findings.push(...checkName(proposal.content.name));
8337
- if (proposal.kind === "new-skill") {
8363
+ if (proposal.skillKind === "new-skill") {
8338
8364
  findings.push(...checkSkillYaml(proposal.content.skillYaml ?? ""));
8339
8365
  findings.push(...checkSkillMd(proposal.content.skillMd ?? ""));
8340
- } else if (proposal.kind === "refinement") {
8366
+ } else if (proposal.skillKind === "refinement") {
8341
8367
  findings.push(...checkDiff(proposal.content.diff ?? ""));
8342
8368
  }
8343
8369
  return findings;
@@ -8345,6 +8371,9 @@ function deriveFindings(proposal) {
8345
8371
  async function runGate(projectPath, proposalId) {
8346
8372
  const proposal = await getProposal(projectPath, proposalId);
8347
8373
  if (!proposal) throw new ProposalNotFoundError(proposalId);
8374
+ if (proposal.kind !== "skill") {
8375
+ throw new GateRunError(`gate applies to skill proposals only (got ${proposal.kind})`);
8376
+ }
8348
8377
  if (proposal.status === "approved" || proposal.status === "rejected") {
8349
8378
  throw new GateRunError(
8350
8379
  `proposal ${proposalId} is already ${proposal.status}; cannot re-run the gate`
@@ -8474,8 +8503,11 @@ async function promoteRefinement(projectPath, proposal) {
8474
8503
  async function promote(projectPath, proposalId, decidedBy) {
8475
8504
  const proposal = await getProposal2(projectPath, proposalId);
8476
8505
  if (!proposal) throw new ProposalNotFoundError2(proposalId);
8506
+ if (proposal.kind !== "skill") {
8507
+ throw new PromotionError("only skill proposals are promotable to the catalog");
8508
+ }
8477
8509
  assertGateReady(proposal);
8478
- const out = proposal.kind === "new-skill" ? await promoteNewSkill(projectPath, proposal) : await promoteRefinement(projectPath, proposal);
8510
+ const out = proposal.skillKind === "new-skill" ? await promoteNewSkill(projectPath, proposal) : await promoteRefinement(projectPath, proposal);
8479
8511
  await updateProposal2(projectPath, proposalId, {
8480
8512
  status: "approved",
8481
8513
  decision: {
@@ -8498,7 +8530,7 @@ function emit3(bus, topic, data) {
8498
8530
  function emitProposalCreated(bus, proposal) {
8499
8531
  const data = {
8500
8532
  id: proposal.id,
8501
- kind: proposal.kind,
8533
+ kind: proposal.skillKind,
8502
8534
  name: proposal.content.name,
8503
8535
  proposedBy: proposal.proposedBy,
8504
8536
  justification: proposal.source.justification
@@ -8509,7 +8541,7 @@ function emitProposalCreated(bus, proposal) {
8509
8541
  function emitProposalApproved(bus, proposal) {
8510
8542
  const data = {
8511
8543
  id: proposal.id,
8512
- kind: proposal.kind,
8544
+ kind: proposal.skillKind,
8513
8545
  name: proposal.content.name,
8514
8546
  decidedBy: proposal.decision?.decidedBy ?? "(unknown)"
8515
8547
  };
@@ -8519,7 +8551,7 @@ function emitProposalApproved(bus, proposal) {
8519
8551
  function emitProposalRejected(bus, proposal) {
8520
8552
  const data = {
8521
8553
  id: proposal.id,
8522
- kind: proposal.kind,
8554
+ kind: proposal.skillKind,
8523
8555
  name: proposal.content.name,
8524
8556
  decidedBy: proposal.decision?.decidedBy ?? "(unknown)",
8525
8557
  reason: proposal.decision?.reason ?? "(no reason given)"
@@ -8527,6 +8559,163 @@ function emitProposalRejected(bus, proposal) {
8527
8559
  emit3(bus, "proposal.rejected", data);
8528
8560
  }
8529
8561
 
8562
+ // src/proposals/model-handlers.ts
8563
+ function isInUse(deps, ollamaName) {
8564
+ return deps.isModelInUse ? deps.isModelInUse(ollamaName) : false;
8565
+ }
8566
+ var MODEL_PROPOSAL_TOPIC = "local-models:proposal";
8567
+ var MODEL_POOL_TOPIC = "local-models:pool";
8568
+ function decisionOf(deps, action, reason) {
8569
+ return {
8570
+ decidedAt: (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
8571
+ decidedBy: deps.decidedBy ?? "orchestrator",
8572
+ action,
8573
+ ...reason !== void 0 ? { reason } : {}
8574
+ };
8575
+ }
8576
+ function emitApproved(deps, proposal) {
8577
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8578
+ id: proposal.id,
8579
+ status: "approved",
8580
+ action: proposal.model.action,
8581
+ target: proposal.model.target.ollamaName
8582
+ });
8583
+ }
8584
+ async function deferEviction(deps, proposal, deferredName, event, evicted) {
8585
+ deps.pool.markPendingEviction(deferredName);
8586
+ const updated = await deps.updateProposal(proposal.id, {
8587
+ status: "approved",
8588
+ decision: decisionOf(deps, "approved")
8589
+ });
8590
+ deps.bus.emit(MODEL_POOL_TOPIC, event);
8591
+ emitApproved(deps, proposal);
8592
+ return { status: "approved", proposal: updated, evicted };
8593
+ }
8594
+ async function onApproveModelProposal(deps, proposal) {
8595
+ const { model } = proposal;
8596
+ if (model.action === "evict") {
8597
+ return applyEvictOnly(deps, proposal);
8598
+ }
8599
+ const installResult = await deps.pool.install({
8600
+ hfRepoId: model.target.hfRepoId,
8601
+ ollamaName: model.target.ollamaName,
8602
+ ...model.replaces !== void 0 ? { replaces: model.replaces.ollamaName } : {},
8603
+ ...model.diskImpactGb > 0 ? { sizeOnDiskGb: model.diskImpactGb } : {}
8604
+ });
8605
+ if (installResult.status === "error") {
8606
+ if (installResult.code === "failed_target_missing") {
8607
+ const updated2 = await deps.updateProposal(proposal.id, {
8608
+ status: "failed_target_missing"
8609
+ });
8610
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8611
+ id: proposal.id,
8612
+ status: "failed_target_missing",
8613
+ action: model.action,
8614
+ target: model.target.ollamaName
8615
+ });
8616
+ return { status: "failed_target_missing", proposal: updated2 };
8617
+ }
8618
+ return { status: "error", code: installResult.code, message: installResult.message };
8619
+ }
8620
+ const evicted = [...installResult.evicted];
8621
+ if (model.action === "swap" && model.replaces !== void 0) {
8622
+ const replacesName = model.replaces.ollamaName;
8623
+ if (isInUse(deps, replacesName)) {
8624
+ return deferEviction(
8625
+ deps,
8626
+ proposal,
8627
+ replacesName,
8628
+ {
8629
+ id: proposal.id,
8630
+ action: model.action,
8631
+ installed: model.target.ollamaName,
8632
+ phase: "evict_deferred",
8633
+ deferred: replacesName,
8634
+ ...installResult.evicted.length > 0 ? { evicted: installResult.evicted.map((e) => e.ollamaName) } : {}
8635
+ },
8636
+ evicted
8637
+ );
8638
+ }
8639
+ const evictResult = await deps.pool.evict({ ollamaName: replacesName });
8640
+ if (evictResult.status === "error") {
8641
+ deps.bus.emit(MODEL_POOL_TOPIC, {
8642
+ id: proposal.id,
8643
+ action: model.action,
8644
+ installed: model.target.ollamaName,
8645
+ phase: "swap_evict_failed"
8646
+ });
8647
+ return { status: "error", code: evictResult.code, message: evictResult.message };
8648
+ }
8649
+ if (evictResult.removed !== null) {
8650
+ evicted.push(evictResult.removed);
8651
+ }
8652
+ }
8653
+ const updated = await deps.updateProposal(proposal.id, {
8654
+ status: "approved",
8655
+ decision: decisionOf(deps, "approved")
8656
+ });
8657
+ const evictedNames = [
8658
+ ...installResult.evicted.map((e) => e.ollamaName),
8659
+ ...model.replaces !== void 0 ? [model.replaces.ollamaName] : []
8660
+ ];
8661
+ deps.bus.emit(MODEL_POOL_TOPIC, {
8662
+ id: proposal.id,
8663
+ action: model.action,
8664
+ installed: model.target.ollamaName,
8665
+ ...evictedNames.length > 0 ? { evicted: evictedNames } : {}
8666
+ });
8667
+ emitApproved(deps, proposal);
8668
+ return { status: "approved", proposal: updated, evicted };
8669
+ }
8670
+ async function applyEvictOnly(deps, proposal) {
8671
+ const targetName = proposal.model.target.ollamaName;
8672
+ if (isInUse(deps, targetName)) {
8673
+ return deferEviction(
8674
+ deps,
8675
+ proposal,
8676
+ targetName,
8677
+ { id: proposal.id, action: "evict", phase: "evict_deferred", deferred: targetName },
8678
+ []
8679
+ );
8680
+ }
8681
+ const evictResult = await deps.pool.evict({ ollamaName: targetName });
8682
+ if (evictResult.status === "error") {
8683
+ return { status: "error", code: evictResult.code, message: evictResult.message };
8684
+ }
8685
+ const updated = await deps.updateProposal(proposal.id, {
8686
+ status: "approved",
8687
+ decision: decisionOf(deps, "approved")
8688
+ });
8689
+ deps.bus.emit(MODEL_POOL_TOPIC, {
8690
+ id: proposal.id,
8691
+ action: "evict",
8692
+ // XP-2: `evicted` is string[] at EVERY local-models:pool emit site (swap/add
8693
+ // list multiple removals) — the evict-only path wraps its single removal in
8694
+ // an array too, so consumers never branch on string-vs-array.
8695
+ evicted: [proposal.model.target.ollamaName]
8696
+ });
8697
+ emitApproved(deps, proposal);
8698
+ return {
8699
+ status: "approved",
8700
+ proposal: updated,
8701
+ evicted: evictResult.removed !== null && evictResult.removed !== void 0 ? [evictResult.removed] : []
8702
+ };
8703
+ }
8704
+ async function onRejectModelProposal(deps, proposal, reason) {
8705
+ const updated = await deps.updateProposal(proposal.id, {
8706
+ status: "rejected",
8707
+ decision: decisionOf(deps, "rejected", reason)
8708
+ });
8709
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8710
+ id: proposal.id,
8711
+ status: "rejected",
8712
+ target: proposal.model.target.ollamaName,
8713
+ replaces: proposal.model.replaces?.ollamaName,
8714
+ reason
8715
+ });
8716
+ return updated;
8717
+ }
8718
+
8530
8719
  // src/server/routes/v1/proposals.ts
8531
8720
  var LIST_RE = /^\/api\/v1\/proposals(?:\?.*)?$/;
8532
8721
  var SINGLE_RE = /^\/api\/v1\/proposals\/([^/?]+)(?:\?.*)?$/;
@@ -8552,6 +8741,20 @@ function getDecidedBy(req, deps) {
8552
8741
  const token = req._authToken;
8553
8742
  return token?.id ?? "unknown";
8554
8743
  }
8744
+ function modelHandlerDeps(deps, req) {
8745
+ return {
8746
+ pool: deps.modelPool,
8747
+ bus: deps.bus,
8748
+ // Core's `updateProposal` patch type intersects the skill+model status
8749
+ // enums, which statically excludes model-only statuses like
8750
+ // `failed_target_missing`. The runtime parses through `ProposalSchema` (the
8751
+ // model variant accepts that status), so the cast is sound — only the
8752
+ // intersection type is too narrow.
8753
+ updateProposal: (id, patch) => updateProposal3(deps.projectPath, id, patch),
8754
+ decidedBy: getDecidedBy(req, deps),
8755
+ ...deps.isModelInUse ? { isModelInUse: deps.isModelInUse } : {}
8756
+ };
8757
+ }
8555
8758
  function parseStatusFromQuery(url) {
8556
8759
  const queryIdx = url.indexOf("?");
8557
8760
  if (queryIdx === -1) return void 0;
@@ -8596,11 +8799,31 @@ async function handleRunGate(res, deps, id) {
8596
8799
  }
8597
8800
  }
8598
8801
  async function handleApprove(req, res, deps, id) {
8802
+ const existing = await getProposal3(deps.projectPath, id);
8803
+ if (!existing) {
8804
+ sendJSON8(res, 404, { error: "Proposal not found" });
8805
+ return;
8806
+ }
8807
+ if (existing.kind === "model") {
8808
+ if (!deps.modelPool) {
8809
+ sendJSON8(res, 501, { error: "model proposal handlers not configured" });
8810
+ return;
8811
+ }
8812
+ if (existing.status === "approved" || existing.status === "rejected" || existing.status === "failed_target_missing") {
8813
+ sendJSON8(res, 409, {
8814
+ error: `proposal already ${existing.status}; cannot approve`
8815
+ });
8816
+ return;
8817
+ }
8818
+ const outcome = await onApproveModelProposal(modelHandlerDeps(deps, req), existing);
8819
+ sendJSON8(res, outcome.status === "error" ? 422 : 200, outcome);
8820
+ return;
8821
+ }
8599
8822
  const decidedBy = getDecidedBy(req, deps);
8600
8823
  try {
8601
8824
  const result = await promote(deps.projectPath, id, decidedBy);
8602
8825
  const proposal = await getProposal3(deps.projectPath, id);
8603
- if (proposal) emitProposalApproved(deps.bus, proposal);
8826
+ if (proposal && proposal.kind === "skill") emitProposalApproved(deps.bus, proposal);
8604
8827
  sendJSON8(res, 200, { promotion: result, proposal });
8605
8828
  } catch (err) {
8606
8829
  if (err instanceof ProposalNotFoundError3) {
@@ -8652,6 +8875,19 @@ async function handleReject(req, res, deps, id) {
8652
8875
  });
8653
8876
  return;
8654
8877
  }
8878
+ if (proposal.kind === "model") {
8879
+ if (!deps.modelPool) {
8880
+ sendJSON8(res, 501, { error: "model proposal handlers not configured" });
8881
+ return;
8882
+ }
8883
+ const updated2 = await onRejectModelProposal(
8884
+ modelHandlerDeps(deps, req),
8885
+ proposal,
8886
+ parsed.data.reason
8887
+ );
8888
+ sendJSON8(res, 200, updated2);
8889
+ return;
8890
+ }
8655
8891
  const decidedBy = getDecidedBy(req, deps);
8656
8892
  const updated = await updateProposal3(deps.projectPath, id, {
8657
8893
  status: "rejected",
@@ -8662,7 +8898,7 @@ async function handleReject(req, res, deps, id) {
8662
8898
  reason: parsed.data.reason
8663
8899
  }
8664
8900
  });
8665
- emitProposalRejected(deps.bus, updated);
8901
+ if (updated.kind === "skill") emitProposalRejected(deps.bus, updated);
8666
8902
  sendJSON8(res, 200, updated);
8667
8903
  }
8668
8904
  async function handleEdit(req, res, deps, id) {
@@ -8690,6 +8926,10 @@ async function handleEdit(req, res, deps, id) {
8690
8926
  sendJSON8(res, 404, { error: "Proposal not found" });
8691
8927
  return;
8692
8928
  }
8929
+ if (existing.kind !== "skill") {
8930
+ sendJSON8(res, 422, { error: "edit applies to skill proposals only" });
8931
+ return;
8932
+ }
8693
8933
  if (existing.status === "approved" || existing.status === "rejected") {
8694
8934
  sendJSON8(res, 409, {
8695
8935
  error: `proposal already ${existing.status}; cannot edit`
@@ -8755,17 +8995,161 @@ function handleV1ProposalsRoute(req, res, deps) {
8755
8995
  return false;
8756
8996
  }
8757
8997
 
8998
+ // src/server/routes/v1/local-models.ts
8999
+ import {
9000
+ isTickHardFailure
9001
+ } from "@harness-engineering/local-models";
9002
+ var REFRESH_RE = /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/;
9003
+ var HARDWARE_RE = /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/;
9004
+ var POOL_RE = /^\/api\/v1\/local-models\/pool(?:\?.*)?$/;
9005
+ var RECS_RE = /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/;
9006
+ var PROPOSALS_RE = /^\/api\/v1\/local-models\/proposals(?:\?.*)?$/;
9007
+ var RECOMMENDATION_PROFILES = ["general", "coding", "reasoning"];
9008
+ function sendJSON9(res, status, body) {
9009
+ res.writeHead(status, { "Content-Type": "application/json" });
9010
+ res.end(JSON.stringify(body));
9011
+ }
9012
+ function handleV1LocalModelsRoute(req, res, deps) {
9013
+ const url = req.url ?? "";
9014
+ const method = req.method ?? "GET";
9015
+ if (method === "GET") {
9016
+ if (HARDWARE_RE.test(url)) {
9017
+ void runGet(res, () => handleHardware(res, deps));
9018
+ return true;
9019
+ }
9020
+ if (POOL_RE.test(url)) {
9021
+ void runGet(res, () => handlePool(res, deps));
9022
+ return true;
9023
+ }
9024
+ if (RECS_RE.test(url)) {
9025
+ void runGet(res, () => handleRecommendations(res, url, deps));
9026
+ return true;
9027
+ }
9028
+ if (PROPOSALS_RE.test(url)) {
9029
+ void runGet(res, () => handleProposals(res, deps));
9030
+ return true;
9031
+ }
9032
+ return false;
9033
+ }
9034
+ if (method !== "POST" || !REFRESH_RE.test(url)) return false;
9035
+ const scheduler = deps.getRefreshScheduler();
9036
+ if (scheduler === null) {
9037
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9038
+ return true;
9039
+ }
9040
+ void runForceRefresh(res, scheduler, deps);
9041
+ return true;
9042
+ }
9043
+ async function runGet(res, handler) {
9044
+ try {
9045
+ await handler();
9046
+ } catch (err) {
9047
+ sendJSON9(res, 500, {
9048
+ error: "local-models route failed",
9049
+ detail: err instanceof Error ? err.message : "unknown"
9050
+ });
9051
+ }
9052
+ }
9053
+ async function handleHardware(res, deps) {
9054
+ const pending = deps.getHardwareProfile?.() ?? null;
9055
+ if (pending === null) {
9056
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9057
+ return;
9058
+ }
9059
+ sendJSON9(res, 200, await pending);
9060
+ }
9061
+ async function handlePool(res, deps) {
9062
+ const pool = deps.getModelPool?.() ?? null;
9063
+ if (pool === null) {
9064
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9065
+ return;
9066
+ }
9067
+ sendJSON9(res, 200, pool.viewState?.() ?? pool.snapshot());
9068
+ }
9069
+ var MAX_RECOMMENDATION_TOP = 100;
9070
+ async function handleRecommendations(res, url, deps) {
9071
+ if (deps.getRecommendations === void 0) {
9072
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9073
+ return;
9074
+ }
9075
+ const params = new URLSearchParams(url.split("?")[1] ?? "");
9076
+ const topRaw = params.get("top");
9077
+ let top = 10;
9078
+ if (topRaw !== null) {
9079
+ const n = Number(topRaw);
9080
+ if (!Number.isInteger(n) || n <= 0) {
9081
+ sendJSON9(res, 400, { error: "invalid top: expected a positive integer" });
9082
+ return;
9083
+ }
9084
+ if (n > MAX_RECOMMENDATION_TOP) {
9085
+ sendJSON9(res, 400, { error: `invalid top: exceeds maximum of ${MAX_RECOMMENDATION_TOP}` });
9086
+ return;
9087
+ }
9088
+ top = n;
9089
+ }
9090
+ const profileRaw = params.get("profile");
9091
+ let profile = "general";
9092
+ if (profileRaw !== null) {
9093
+ if (!isRecommendationProfile(profileRaw)) {
9094
+ sendJSON9(res, 400, {
9095
+ error: `invalid profile: expected one of ${RECOMMENDATION_PROFILES.join(", ")}`
9096
+ });
9097
+ return;
9098
+ }
9099
+ profile = profileRaw;
9100
+ }
9101
+ sendJSON9(res, 200, await deps.getRecommendations({ top, profile }));
9102
+ }
9103
+ async function handleProposals(res, deps) {
9104
+ if (deps.listModelProposals === void 0) {
9105
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9106
+ return;
9107
+ }
9108
+ sendJSON9(res, 200, await deps.listModelProposals());
9109
+ }
9110
+ function isRecommendationProfile(value) {
9111
+ return RECOMMENDATION_PROFILES.includes(value);
9112
+ }
9113
+ async function runForceRefresh(res, scheduler, deps) {
9114
+ let result;
9115
+ try {
9116
+ result = await scheduler.forceRefresh();
9117
+ } catch (err) {
9118
+ sendJSON9(res, 500, {
9119
+ error: "refresh tick failed",
9120
+ detail: err instanceof Error ? err.message : "unknown"
9121
+ });
9122
+ return;
9123
+ }
9124
+ if (isTickHardFailure(result)) {
9125
+ sendJSON9(res, 503, {
9126
+ error: "refresh hard failure: HuggingFace unreachable and no benchmark snapshot loaded",
9127
+ emitted: result.proposalsEmitted,
9128
+ warnings: result.warnings,
9129
+ errors: result.errors
9130
+ });
9131
+ return;
9132
+ }
9133
+ const proposals = deps.listModelProposals ? await deps.listModelProposals() : [];
9134
+ sendJSON9(res, 200, {
9135
+ emitted: result.proposalsEmitted,
9136
+ reconciledRemoved: result.reconciledRemoved,
9137
+ proposals,
9138
+ warnings: result.warnings
9139
+ });
9140
+ }
9141
+
8758
9142
  // src/server/routes/v1/routing.ts
8759
9143
  import { z as z14 } from "zod";
8760
9144
  var CONFIG_RE = /^\/api\/v1\/routing\/config(?:\?.*)?$/;
8761
9145
  var DECISIONS_RE = /^\/api\/v1\/routing\/decisions(?:\?.*)?$/;
8762
9146
  var TRACE_RE = /^\/api\/v1\/routing\/trace(?:\?.*)?$/;
8763
- function sendJSON9(res, status, body) {
9147
+ function sendJSON10(res, status, body) {
8764
9148
  res.writeHead(status, { "Content-Type": "application/json" });
8765
9149
  res.end(JSON.stringify(body));
8766
9150
  }
8767
9151
  function unavailable(res) {
8768
- sendJSON9(res, 503, { error: "BackendRouter not available" });
9152
+ sendJSON10(res, 503, { error: "BackendRouter not available" });
8769
9153
  return true;
8770
9154
  }
8771
9155
  function resolveChain(value, backends) {
@@ -8802,7 +9186,7 @@ function buildResolvedChains(routing, backends) {
8802
9186
  }
8803
9187
  function handleConfig(res, deps) {
8804
9188
  if (!deps.router || !deps.routing || !deps.backends) return unavailable(res);
8805
- sendJSON9(res, 200, {
9189
+ sendJSON10(res, 200, {
8806
9190
  routing: deps.routing,
8807
9191
  resolvedChains: buildResolvedChains(deps.routing, deps.backends),
8808
9192
  backends: Object.keys(deps.backends)
@@ -8830,7 +9214,7 @@ function parseDecisionsQuery(url) {
8830
9214
  function handleDecisions(req, res, deps) {
8831
9215
  if (!deps.bus) return unavailable(res);
8832
9216
  const filter = parseDecisionsQuery(req.url ?? "");
8833
- sendJSON9(res, 200, { decisions: deps.bus.recent(filter) });
9217
+ sendJSON10(res, 200, { decisions: deps.bus.recent(filter) });
8834
9218
  return true;
8835
9219
  }
8836
9220
  var UseCaseSchema = z14.discriminatedUnion("kind", [
@@ -8862,19 +9246,19 @@ async function handleTrace(req, res, deps) {
8862
9246
  try {
8863
9247
  raw = await readBody(req);
8864
9248
  } catch {
8865
- sendJSON9(res, 400, { error: "body read failed" });
9249
+ sendJSON10(res, 400, { error: "body read failed" });
8866
9250
  return true;
8867
9251
  }
8868
9252
  let parsed;
8869
9253
  try {
8870
9254
  parsed = JSON.parse(raw);
8871
9255
  } catch {
8872
- sendJSON9(res, 400, { error: "invalid JSON body" });
9256
+ sendJSON10(res, 400, { error: "invalid JSON body" });
8873
9257
  return true;
8874
9258
  }
8875
9259
  const r = TraceBodySchema.safeParse(parsed);
8876
9260
  if (!r.success) {
8877
- sendJSON9(res, 400, { error: r.error.message });
9261
+ sendJSON10(res, 400, { error: r.error.message });
8878
9262
  return true;
8879
9263
  }
8880
9264
  const opts = r.data.invocationOverride !== void 0 ? { invocationOverride: r.data.invocationOverride } : void 0;
@@ -8887,9 +9271,9 @@ async function handleTrace(req, res, deps) {
8887
9271
  r.data.useCase,
8888
9272
  opts
8889
9273
  );
8890
- sendJSON9(res, 200, { decision, def: { type: def.type } });
9274
+ sendJSON10(res, 200, { decision, def: { type: def.type } });
8891
9275
  } catch (err) {
8892
- sendJSON9(res, 500, { error: String(err) });
9276
+ sendJSON10(res, 500, { error: String(err) });
8893
9277
  }
8894
9278
  return true;
8895
9279
  }
@@ -9130,7 +9514,7 @@ var CreateBodySchema = z16.object({
9130
9514
  tenantId: z16.string().optional(),
9131
9515
  expiresAt: z16.string().datetime().optional()
9132
9516
  });
9133
- function sendJSON10(res, status, body) {
9517
+ function sendJSON11(res, status, body) {
9134
9518
  res.writeHead(status, { "Content-Type": "application/json" });
9135
9519
  res.end(JSON.stringify(body));
9136
9520
  }
@@ -9140,19 +9524,19 @@ async function handlePost(req, res, store) {
9140
9524
  raw = await readBody(req);
9141
9525
  } catch (err) {
9142
9526
  const msg = err instanceof Error ? err.message : "Failed to read body";
9143
- sendJSON10(res, 413, { error: msg });
9527
+ sendJSON11(res, 413, { error: msg });
9144
9528
  return;
9145
9529
  }
9146
9530
  let json;
9147
9531
  try {
9148
9532
  json = JSON.parse(raw);
9149
9533
  } catch {
9150
- sendJSON10(res, 400, { error: "Invalid JSON body" });
9534
+ sendJSON11(res, 400, { error: "Invalid JSON body" });
9151
9535
  return;
9152
9536
  }
9153
9537
  const parsed = CreateBodySchema.safeParse(json);
9154
9538
  if (!parsed.success) {
9155
- sendJSON10(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9539
+ sendJSON11(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9156
9540
  return;
9157
9541
  }
9158
9542
  try {
@@ -9165,37 +9549,37 @@ async function handlePost(req, res, store) {
9165
9549
  if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
9166
9550
  const result = await store.create(input);
9167
9551
  const publicRecord = AuthTokenPublicSchema.parse(result.record);
9168
- sendJSON10(res, 200, {
9552
+ sendJSON11(res, 200, {
9169
9553
  ...publicRecord,
9170
9554
  token: result.token
9171
9555
  });
9172
9556
  } catch (err) {
9173
9557
  const msg = err instanceof Error ? err.message : "Failed to create token";
9174
9558
  if (msg.includes("already exists")) {
9175
- sendJSON10(res, 409, { error: msg });
9559
+ sendJSON11(res, 409, { error: msg });
9176
9560
  return;
9177
9561
  }
9178
- sendJSON10(res, 500, { error: "Internal error creating token" });
9562
+ sendJSON11(res, 500, { error: "Internal error creating token" });
9179
9563
  }
9180
9564
  }
9181
9565
  async function handleList3(res, store) {
9182
9566
  try {
9183
9567
  const list = await store.list();
9184
- sendJSON10(res, 200, list);
9568
+ sendJSON11(res, 200, list);
9185
9569
  } catch {
9186
- sendJSON10(res, 500, { error: "Internal error listing tokens" });
9570
+ sendJSON11(res, 500, { error: "Internal error listing tokens" });
9187
9571
  }
9188
9572
  }
9189
9573
  async function handleDelete2(res, store, id) {
9190
9574
  try {
9191
9575
  const ok = await store.revoke(id);
9192
9576
  if (!ok) {
9193
- sendJSON10(res, 404, { error: "Token not found" });
9577
+ sendJSON11(res, 404, { error: "Token not found" });
9194
9578
  return;
9195
9579
  }
9196
- sendJSON10(res, 200, { deleted: true });
9580
+ sendJSON11(res, 200, { deleted: true });
9197
9581
  } catch {
9198
- sendJSON10(res, 500, { error: "Internal error revoking token" });
9582
+ sendJSON11(res, 500, { error: "Internal error revoking token" });
9199
9583
  }
9200
9584
  }
9201
9585
  var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
@@ -9220,12 +9604,12 @@ function handleAuthRoute(req, res, store) {
9220
9604
  return true;
9221
9605
  }
9222
9606
  }
9223
- sendJSON10(res, 405, { error: "Method not allowed" });
9607
+ sendJSON11(res, 405, { error: "Method not allowed" });
9224
9608
  return true;
9225
9609
  }
9226
9610
 
9227
9611
  // src/server/routes/local-model.ts
9228
- function sendJSON11(res, status, body) {
9612
+ function sendJSON12(res, status, body) {
9229
9613
  res.writeHead(status, { "Content-Type": "application/json" });
9230
9614
  res.end(JSON.stringify(body));
9231
9615
  }
@@ -9233,30 +9617,30 @@ function handleLocalModelRoute(req, res, getStatus) {
9233
9617
  const { method, url } = req;
9234
9618
  if (url !== "/api/v1/local-model/status") return false;
9235
9619
  if (method !== "GET") {
9236
- sendJSON11(res, 405, { error: "Method not allowed" });
9620
+ sendJSON12(res, 405, { error: "Method not allowed" });
9237
9621
  return true;
9238
9622
  }
9239
9623
  if (!getStatus) {
9240
- sendJSON11(res, 503, { error: "Local backend not configured" });
9624
+ sendJSON12(res, 503, { error: "Local backend not configured" });
9241
9625
  return true;
9242
9626
  }
9243
9627
  const status = getStatus();
9244
9628
  if (!status) {
9245
- sendJSON11(res, 503, { error: "Local backend not configured" });
9629
+ sendJSON12(res, 503, { error: "Local backend not configured" });
9246
9630
  return true;
9247
9631
  }
9248
- sendJSON11(res, 200, status);
9632
+ sendJSON12(res, 200, status);
9249
9633
  return true;
9250
9634
  }
9251
9635
  function handleLocalModelsRoute(req, res, getStatuses) {
9252
9636
  const { method, url } = req;
9253
9637
  if (url !== "/api/v1/local-models/status") return false;
9254
9638
  if (method !== "GET") {
9255
- sendJSON11(res, 405, { error: "Method not allowed" });
9639
+ sendJSON12(res, 405, { error: "Method not allowed" });
9256
9640
  return true;
9257
9641
  }
9258
9642
  const statuses = getStatuses ? getStatuses() : [];
9259
- sendJSON11(res, 200, statuses);
9643
+ sendJSON12(res, 200, statuses);
9260
9644
  return true;
9261
9645
  }
9262
9646
 
@@ -9623,6 +10007,45 @@ var V1_BRIDGE_ROUTES = [
9623
10007
  scope: "read-telemetry",
9624
10008
  description: "Prompt-cache hit/miss snapshot (rolling window)."
9625
10009
  },
10010
+ // ── LMLM Phase 6 — force-refresh (background scheduler tick, out of band) ──
10011
+ // Reuses `manage-proposals`: a forced refresh emits model proposals, so the
10012
+ // same write scope that governs approve/reject governs triggering the tick.
10013
+ {
10014
+ method: "POST",
10015
+ pattern: /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/,
10016
+ scope: "manage-proposals",
10017
+ description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
10018
+ },
10019
+ // ── LMLM Phase 7 — read surface (hardware/pool/recommendations/proposals) ──
10020
+ // Load-bearing: `local-models` is in `V1_WRAPPABLE`, so without these entries
10021
+ // the `/api/v1` rewrite shim would rewrite `/api/v1/local-models/<name>` →
10022
+ // `/api/local-models/<name>` and misroute it to the legacy status handler.
10023
+ // `isV1Bridge` short-circuits the rewrite; `requiredBridgeScope` supplies the
10024
+ // default-deny read scope. All four are read-only observability (`read-status`).
10025
+ {
10026
+ method: "GET",
10027
+ pattern: /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/,
10028
+ scope: "read-status",
10029
+ description: "Current detected HardwareProfile (RAM/VRAM/GPU)."
10030
+ },
10031
+ {
10032
+ method: "GET",
10033
+ pattern: /^\/api\/v1\/local-models\/pool(?:\?.*)?$/,
10034
+ scope: "read-status",
10035
+ description: "Live PoolState view, including transient pendingEviction flags."
10036
+ },
10037
+ {
10038
+ method: "GET",
10039
+ pattern: /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/,
10040
+ scope: "read-status",
10041
+ description: "Hardware-ranked model recommendations (top/profile query params)."
10042
+ },
10043
+ {
10044
+ method: "GET",
10045
+ pattern: /^\/api\/v1\/local-models\/proposals(?:\?.*)?$/,
10046
+ scope: "read-status",
10047
+ description: "Open model-kind proposals (pending review queue)."
10048
+ },
9626
10049
  // ── Spec B Phase 5 routing observability ──
9627
10050
  // D-OP-1: all three reuse `read-telemetry` — matches the cacheMetrics
9628
10051
  // precedent (read-only observability). A dedicated `read-routing`
@@ -9765,7 +10188,19 @@ var OrchestratorServer = class {
9765
10188
  getRoutingDecisionBusFn = null;
9766
10189
  getRoutingConfigFn = null;
9767
10190
  getBackendsFn = null;
10191
+ // LMLM Phase 6 — live model pool + refresh scheduler accessors.
10192
+ getModelPoolFn = null;
10193
+ getRefreshSchedulerFn = null;
10194
+ // LMLM Phase 7 — hardware / recommendations / model-proposal read accessors.
10195
+ getHardwareProfileFn = null;
10196
+ getRecommendationsFn = null;
10197
+ listModelProposalsFn = null;
10198
+ // LMLM Phase 7 / S1 — in-use probe threaded into kind:'model' approve.
10199
+ isModelInUseFn = null;
9768
10200
  routingDecisionUnsubscribe = null;
10201
+ // LMLM Phase 7 — bus→WS fan-out listeners for the model proposal/pool topics.
10202
+ modelProposalListener = null;
10203
+ modelPoolListener = null;
9769
10204
  recorder = null;
9770
10205
  planWatcher = null;
9771
10206
  tokenStore;
@@ -9810,6 +10245,12 @@ var OrchestratorServer = class {
9810
10245
  this.getRoutingDecisionBusFn = deps?.getRoutingDecisionBus ?? null;
9811
10246
  this.getRoutingConfigFn = deps?.getRoutingConfig ?? null;
9812
10247
  this.getBackendsFn = deps?.getBackends ?? null;
10248
+ this.getModelPoolFn = deps?.getModelPool ?? null;
10249
+ this.getRefreshSchedulerFn = deps?.getRefreshScheduler ?? null;
10250
+ this.getHardwareProfileFn = deps?.getHardwareProfile ?? null;
10251
+ this.getRecommendationsFn = deps?.getRecommendations ?? null;
10252
+ this.listModelProposalsFn = deps?.listModelProposals ?? null;
10253
+ this.isModelInUseFn = deps?.isModelInUse ?? null;
9813
10254
  }
9814
10255
  wireEvents() {
9815
10256
  this.stateChangeListener = (snapshot) => {
@@ -9826,6 +10267,10 @@ var OrchestratorServer = class {
9826
10267
  this.broadcaster.broadcast("routing:decision", decision);
9827
10268
  });
9828
10269
  }
10270
+ this.modelProposalListener = (data) => this.broadcaster.broadcast(MODEL_PROPOSAL_TOPIC, data);
10271
+ this.modelPoolListener = (data) => this.broadcaster.broadcast(MODEL_POOL_TOPIC, data);
10272
+ this.orchestrator.on(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
10273
+ this.orchestrator.on(MODEL_POOL_TOPIC, this.modelPoolListener);
9829
10274
  }
9830
10275
  /**
9831
10276
  * Broadcast a new interaction to all WebSocket clients.
@@ -9994,9 +10439,27 @@ var OrchestratorServer = class {
9994
10439
  // upstream by V1_BRIDGE_ROUTES; this dispatcher only handles
9995
10440
  // business logic. `projectPath` defaults to process.cwd() — that is
9996
10441
  // where `.harness/proposals/` lives in every deployment we ship.
9997
- (req, res) => handleV1ProposalsRoute(req, res, {
9998
- projectPath: this.projectPath,
9999
- bus: this.orchestrator
10442
+ (req, res) => {
10443
+ const modelPool = this.getModelPoolFn?.() ?? null;
10444
+ return handleV1ProposalsRoute(req, res, {
10445
+ projectPath: this.projectPath,
10446
+ bus: this.orchestrator,
10447
+ ...modelPool ? { modelPool } : {},
10448
+ // S1: thread the in-use probe so an approved swap/evict of a model an
10449
+ // agent could be using is deferred (marked pendingEviction) not applied.
10450
+ ...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
10451
+ });
10452
+ },
10453
+ // LMLM Phase 6/7 — POST /refresh + the GET read surface
10454
+ // (hardware/pool/recommendations/proposals). Registered before the
10455
+ // chat-proxy fallback so it owns the path. Each accessor is spread in
10456
+ // only when configured so absent ones surface as 503 (LMLM disabled).
10457
+ (req, res) => handleV1LocalModelsRoute(req, res, {
10458
+ getRefreshScheduler: this.getRefreshSchedulerFn ?? (() => null),
10459
+ getModelPool: () => this.getModelPoolFn?.() ?? null,
10460
+ ...this.getHardwareProfileFn ? { getHardwareProfile: this.getHardwareProfileFn } : {},
10461
+ ...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
10462
+ ...this.listModelProposalsFn ? { listModelProposals: this.listModelProposalsFn } : {}
10000
10463
  }),
10001
10464
  // Chat proxy route (spawns Claude Code CLI — no API key required)
10002
10465
  (req, res) => handleChatProxyRoute(req, res, this.claudeCommand)
@@ -10100,6 +10563,14 @@ var OrchestratorServer = class {
10100
10563
  this.routingDecisionUnsubscribe();
10101
10564
  this.routingDecisionUnsubscribe = null;
10102
10565
  }
10566
+ if (this.modelProposalListener) {
10567
+ this.orchestrator.removeListener(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
10568
+ this.modelProposalListener = null;
10569
+ }
10570
+ if (this.modelPoolListener) {
10571
+ this.orchestrator.removeListener(MODEL_POOL_TOPIC, this.modelPoolListener);
10572
+ this.modelPoolListener = null;
10573
+ }
10103
10574
  if (this.planWatcher) {
10104
10575
  this.planWatcher.stop();
10105
10576
  this.planWatcher = null;
@@ -11015,8 +11486,50 @@ var ENVELOPE_DERIVERS = {
11015
11486
  summary: truncate(data.reason ?? "No reason provided.", 240),
11016
11487
  severity: "warning"
11017
11488
  };
11018
- }
11489
+ },
11490
+ // LMLM Phase 7 — model-proposal lifecycle. Delegated to a status-keyed
11491
+ // lookup (below) so each status branch is its own small function and the
11492
+ // deriver stays under the cyclomatic-complexity gate.
11493
+ "local-models.proposal": (event) => deriveModelProposalEnvelope(event)
11494
+ };
11495
+ var MODEL_PROPOSAL_ENVELOPES = {
11496
+ created: (data) => {
11497
+ const label = `${data.action ?? "change"} ${data.target ?? "(unknown model)"}`;
11498
+ return {
11499
+ title: `New model proposal: ${label}`,
11500
+ summary: `A model proposal (${label}) is awaiting review.`,
11501
+ severity: "info"
11502
+ };
11503
+ },
11504
+ approved: (data) => {
11505
+ const label = `${data.action ?? "change"} ${data.target ?? "(unknown model)"}`;
11506
+ return {
11507
+ title: `Model proposal approved: ${label}`,
11508
+ summary: `The model proposal (${label}) was approved and applied to the pool.`,
11509
+ severity: "success"
11510
+ };
11511
+ },
11512
+ rejected: (data) => ({
11513
+ title: "Model proposal rejected",
11514
+ summary: truncate(data.reason ?? "No reason provided.", 240),
11515
+ severity: "warning"
11516
+ }),
11517
+ failed_target_missing: (data) => ({
11518
+ title: "Model proposal target missing",
11519
+ summary: `The proposed model ${data.target ?? "(unknown model)"} is no longer available; the proposal was closed.`,
11520
+ severity: "error"
11521
+ })
11019
11522
  };
11523
+ function deriveModelProposalEnvelope(event) {
11524
+ const data = asObj(event.data);
11525
+ const build = MODEL_PROPOSAL_ENVELOPES[data.status ?? ""];
11526
+ if (build) return build(data);
11527
+ return {
11528
+ title: `Model proposal update: ${data.target ?? "(unknown model)"}`,
11529
+ summary: `Model proposal status: ${data.status ?? "(unknown)"}.`,
11530
+ severity: "info"
11531
+ };
11532
+ }
11020
11533
  function truncate(s, max) {
11021
11534
  return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
11022
11535
  }
@@ -11063,7 +11576,9 @@ var NOTIFICATION_TOPICS = [
11063
11576
  // Hermes Phase 4 — skill proposal lifecycle.
11064
11577
  "proposal.created",
11065
11578
  "proposal.approved",
11066
- "proposal.rejected"
11579
+ "proposal.rejected",
11580
+ // LMLM Phase 7 — model-proposal lifecycle (colon→dot ⇒ 'local-models.proposal').
11581
+ "local-models:proposal"
11067
11582
  ];
11068
11583
  function newEventId3() {
11069
11584
  return `evt_${randomBytes7(8).toString("hex")}`;
@@ -13156,6 +13671,45 @@ var Orchestrator = class extends EventEmitter {
13156
13671
  * so this map is the single source of truth post-migration.
13157
13672
  */
13158
13673
  localResolvers = /* @__PURE__ */ new Map();
13674
+ /** Phase 4 (D5): pool-state port shared by all local/pi resolvers. Null when LMLM disabled. */
13675
+ poolStateProvider = null;
13676
+ poolStateStore = null;
13677
+ /**
13678
+ * LMLM Phase 6: live model pool + its installer. Constructed only when
13679
+ * `localModels.enabled` and a real `PoolStateStore` exists (not a test
13680
+ * override). Exposed to the server via `getModelPool()`, which retires the
13681
+ * proposals-route 501 stub for `kind: 'model'` approve/reject. Null when LMLM
13682
+ * is disabled. `PoolManager` reads `store.snapshot()` lazily, so constructing
13683
+ * before `store.load()` (in initLocalModelAndPipeline) is safe.
13684
+ */
13685
+ modelPool = null;
13686
+ modelInstaller = null;
13687
+ /**
13688
+ * S1 drain re-entrancy guard (P7-SUG-DRAIN-REENTRANCY). `drainDeferredEvictions`
13689
+ * is fired fire-and-forget from `emitWorkerExit` (and, since P7-SUG-DRAIN-LIVENESS,
13690
+ * piggybacked on each refresh tick). Two overlapping drains would both read the
13691
+ * same `listPendingEvictions()` snapshot, both re-check `isLocalModelInUse`, and
13692
+ * both `await pool.evict` the SAME name — double-calling the installer and
13693
+ * broadcasting a duplicate `evict_completed` frame. The single-threaded event
13694
+ * loop makes a plain boolean sufficient: a drain that arrives while one is
13695
+ * running returns early rather than double-processing.
13696
+ */
13697
+ draining = false;
13698
+ /**
13699
+ * LMLM Phase 6: single per-instance background refresh scheduler. Started in
13700
+ * `initLocalModelAndPipeline` when the pool exists; stopped in `stop()`. Null
13701
+ * when LMLM is disabled. Exposed to the server via `getRefreshScheduler()`.
13702
+ */
13703
+ refreshScheduler = null;
13704
+ /**
13705
+ * LMLM Phase 7: the hardware-aware recommender bound at scheduler start. Reused
13706
+ * by `GET /api/v1/local-models/recommendations`. Null when LMLM is disabled (no
13707
+ * pool → scheduler never armed). Ranks the (currently empty) candidate set —
13708
+ * see the Phase 2 candidate-parser gap noted on `startRefreshScheduler`.
13709
+ */
13710
+ modelRecommender = null;
13711
+ /** Test seam: injected timer/clock for the scheduler so no real 24h timer runs. */
13712
+ schedulerTimerOverride;
13159
13713
  /**
13160
13714
  * Spec B Phase 3: skill catalog (name + cognitiveMode) read once at
13161
13715
  * construction from `projectRoot/agents/skills/`. Consulted by
@@ -13246,6 +13800,7 @@ var Orchestrator = class extends EventEmitter {
13246
13800
  */
13247
13801
  constructor(config, promptTemplate, overrides) {
13248
13802
  super();
13803
+ this.schedulerTimerOverride = overrides?.schedulerTimer ?? null;
13249
13804
  this.setMaxListeners(50);
13250
13805
  this.config = config;
13251
13806
  this.promptTemplate = promptTemplate;
@@ -13293,6 +13848,16 @@ var Orchestrator = class extends EventEmitter {
13293
13848
  this
13294
13849
  );
13295
13850
  this.analysisArchive = new AnalysisArchive(path21.join(config.workspace.root, "..", "analyses"));
13851
+ const localModelsEnabled = this.config.localModels?.enabled === true;
13852
+ if (overrides?.poolState) {
13853
+ this.poolStateProvider = overrides.poolState;
13854
+ } else if (localModelsEnabled) {
13855
+ this.poolStateStore = new PoolStateStore({
13856
+ onWarn: (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0)
13857
+ });
13858
+ this.poolStateProvider = this.poolStateStore;
13859
+ this.initModelPool(this.poolStateStore);
13860
+ }
13296
13861
  const backendsMap = this.config.agent.backends ?? {};
13297
13862
  for (const [name, def] of Object.entries(backendsMap)) {
13298
13863
  if (def.type === "local" || def.type === "pi") {
@@ -13303,6 +13868,7 @@ var Orchestrator = class extends EventEmitter {
13303
13868
  };
13304
13869
  if (def.apiKey !== void 0) resolverOpts.apiKey = def.apiKey;
13305
13870
  if (def.probeIntervalMs !== void 0) resolverOpts.probeIntervalMs = def.probeIntervalMs;
13871
+ if (this.poolStateProvider !== null) resolverOpts.poolState = this.poolStateProvider;
13306
13872
  this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
13307
13873
  }
13308
13874
  }
@@ -13414,7 +13980,26 @@ var Orchestrator = class extends EventEmitter {
13414
13980
  roadmapPath: config.tracker.filePath ?? null,
13415
13981
  dispatchAdHoc: this.dispatchAdHoc.bind(this),
13416
13982
  getLocalModelStatus: () => this.getFirstLocalModelStatus(),
13417
- getLocalModelStatuses: () => this.buildLocalModelStatuses()
13983
+ getLocalModelStatuses: () => this.buildLocalModelStatuses(),
13984
+ // LMLM Phase 6: expose the live pool so kind:'model' approve/reject
13985
+ // reaches PoolManager (retiring the 501). Null when LMLM is disabled.
13986
+ getModelPool: () => this.modelPool,
13987
+ // LMLM Phase 7 / S1: conservative in-use probe so an approved swap/evict
13988
+ // of a model an agent could be using is DEFERRED, not applied mid-request
13989
+ // (ADR 0060). Agent-run-coarse; may over-defer (safe).
13990
+ isModelInUse: (ollamaName) => this.isLocalModelInUse(ollamaName),
13991
+ // LMLM Phase 6: expose the refresh scheduler for POST /local-models/refresh.
13992
+ getRefreshScheduler: () => this.refreshScheduler,
13993
+ // LMLM Phase 7 read surface — hardware / recommendations / model proposals.
13994
+ // Each returns null/[] when LMLM is disabled so the route renders 503/[].
13995
+ getHardwareProfile: () => this.modelPool ? this.detectLmlmHardware() : null,
13996
+ getRecommendations: async ({ top }) => {
13997
+ if (this.modelRecommender === null) return [];
13998
+ const hardware = await this.detectLmlmHardware();
13999
+ const { ranked } = await this.modelRecommender(hardware);
14000
+ return ranked.slice(0, top);
14001
+ },
14002
+ listModelProposals: () => listProposals2(this.projectRoot, { status: "open", kind: "model" })
13418
14003
  });
13419
14004
  this.server.setRecorder(this.recorder);
13420
14005
  this.interactionQueue.onPush((interaction) => {
@@ -13472,6 +14057,60 @@ var Orchestrator = class extends EventEmitter {
13472
14057
  }
13473
14058
  return out;
13474
14059
  }
14060
+ /**
14061
+ * S1 conservative in-use probe (ADR 0060). Returns `true` when ANY agent run
14062
+ * is live AND `ollamaName` is a currently-resolved (or last-detected) local
14063
+ * model — i.e. an agent could be routing inference to it right now.
14064
+ *
14065
+ * This signal is AGENT-RUN-COARSE, not per-request: `state.running` is keyed
14066
+ * by GitHub issue (spawned agent runs), NOT by inference call, and no
14067
+ * per-model request counter exists today. The probe therefore MAY OVER-DEFER
14068
+ * (a swap waits until the pool is idle). Over-deferral is exactly S1's
14069
+ * intended safe failure — never yank a model mid-request; occasionally wait
14070
+ * longer than strictly necessary. A fine-grained per-request signal is an
14071
+ * explicit deferred gap (ADR 0060).
14072
+ */
14073
+ isLocalModelInUse(ollamaName) {
14074
+ if (this.state.running.size === 0) return false;
14075
+ return this.buildLocalModelStatuses().some(
14076
+ (s) => s.resolved === ollamaName || s.detected.includes(ollamaName)
14077
+ );
14078
+ }
14079
+ /**
14080
+ * S1 drain (ADR 0060): complete any eviction that was DEFERRED because its
14081
+ * target was in use, now that the probe reports it idle. Best-effort — called
14082
+ * from the run-completion path; it never blocks dispatch and swallows
14083
+ * per-model errors (a failed or still-busy evict stays pending for the next
14084
+ * drain). `pendingEviction` is a transient overlay, so a missed drain simply
14085
+ * leaves the flag set until the next completion re-checks it.
14086
+ */
14087
+ async drainDeferredEvictions() {
14088
+ const pool = this.modelPool;
14089
+ if (pool === null) return;
14090
+ if (this.draining) return;
14091
+ this.draining = true;
14092
+ try {
14093
+ for (const ollamaName of pool.listPendingEvictions()) {
14094
+ if (this.isLocalModelInUse(ollamaName)) continue;
14095
+ try {
14096
+ const result = await pool.evict({ ollamaName });
14097
+ if (result.status === "error") continue;
14098
+ pool.clearPendingEviction(ollamaName);
14099
+ this.emit("local-models:pool", {
14100
+ action: "evict",
14101
+ // XP-2: `evicted` is uniformly string[] across all local-models:pool
14102
+ // emit sites — the drain wraps its single completed eviction in an
14103
+ // array to match the swap/add multi-evict shape.
14104
+ evicted: [ollamaName],
14105
+ phase: "evict_completed"
14106
+ });
14107
+ } catch {
14108
+ }
14109
+ }
14110
+ } finally {
14111
+ this.draining = false;
14112
+ }
14113
+ }
13475
14114
  createTracker() {
13476
14115
  if (this.config.tracker.kind === "github-issues") {
13477
14116
  const trackerCfg = {
@@ -14318,6 +14957,7 @@ ${messages}`);
14318
14957
  error,
14319
14958
  (effect) => this.handleEffect(effect)
14320
14959
  );
14960
+ void this.drainDeferredEvictions();
14321
14961
  this.emit("state_change", this.getSnapshot());
14322
14962
  }
14323
14963
  /**
@@ -14405,6 +15045,91 @@ ${messages}`);
14405
15045
  * before constructing the intelligence pipeline. Subscribes the dashboard
14406
15046
  * broadcast stub to status changes. Called exactly once from start().
14407
15047
  */
15048
+ /**
15049
+ * LMLM Phase 6: construct the live `PoolManager` (Ollama installer + the
15050
+ * loaded pool-state store) and stash it for `getModelPool()`. Defensive
15051
+ * config fallbacks: `ollamaEndpoint → http://localhost:11434`. The pool reads
15052
+ * `store.snapshot()` lazily, so this runs safely at construction time before
15053
+ * `store.load()`.
15054
+ */
15055
+ initModelPool(store) {
15056
+ const onWarn = (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0);
15057
+ const installerCfg = this.config.localModels?.installer;
15058
+ this.modelInstaller = new OllamaInstallAdapter({
15059
+ baseUrl: installerCfg?.ollamaEndpoint ?? "http://localhost:11434",
15060
+ onWarn
15061
+ });
15062
+ this.modelPool = new PoolManager({ store, installer: this.modelInstaller, onWarn });
15063
+ }
15064
+ /**
15065
+ * LMLM Phase 6: arm the single background refresh scheduler over the live
15066
+ * pool. No-op when LMLM is disabled (`modelPool` null). Each tick runs
15067
+ * hardware→recommend→reconcile(D12 drift)→diff→emit→score-writeback.
15068
+ *
15069
+ * NOTE (deferred): the recommender is seeded with an empty candidate set —
15070
+ * Phase 2's live-HF→RankerCandidate parser was never built, so autonomous
15071
+ * swap-proposal discovery is out of scope here (flagged concern). The tick
15072
+ * still performs F10 drift reconciliation, O1 logging, and re-ranks/dedups
15073
+ * whatever candidates are supplied — the wiring is complete and candidate
15074
+ * breadth is the only piece deferred to the Phase 2 recommender.
15075
+ */
15076
+ startRefreshScheduler() {
15077
+ if (this.modelPool === null) return;
15078
+ const pool = this.modelPool;
15079
+ const refreshCfg = this.config.localModels?.refresh;
15080
+ const recommend = createNativeRecommender({ candidates: [] });
15081
+ this.modelRecommender = recommend;
15082
+ this.refreshScheduler = new RefreshScheduler({
15083
+ runTick: () => runRefreshTick({
15084
+ detectHardware: () => this.detectLmlmHardware(),
15085
+ recommend,
15086
+ poolManager: pool,
15087
+ dedupSource: () => this.lmlmDedupSource(),
15088
+ // Phase 7: after persisting the proposal, emit `local-models:proposal`
15089
+ // (== MODEL_PROPOSAL_TOPIC) on the bus so it fans out to WS clients and
15090
+ // notification sinks. Literal to avoid a proposals/model-handlers cycle.
15091
+ emitProposal: (c) => createModelProposal(this.projectRoot, c).then((record) => {
15092
+ this.emit("local-models:proposal", {
15093
+ id: record.id,
15094
+ status: "created",
15095
+ action: c.action,
15096
+ target: c.target.ollamaName
15097
+ });
15098
+ }),
15099
+ proposalThreshold: refreshCfg?.proposalThreshold ?? 5
15100
+ }).then((result) => {
15101
+ void this.drainDeferredEvictions();
15102
+ return result;
15103
+ }),
15104
+ intervalMs: refreshCfg?.intervalMs ?? 864e5,
15105
+ jitterMs: refreshCfg?.jitterMs ?? 6e5,
15106
+ logger: this.logger,
15107
+ ...this.schedulerTimerOverride ?? {}
15108
+ });
15109
+ this.refreshScheduler.start();
15110
+ }
15111
+ /** Resolve the hardware profile for a refresh tick (operator override wins). */
15112
+ async detectLmlmHardware() {
15113
+ const override = this.config.localModels?.hardware?.override;
15114
+ const detector = new HardwareDetector(override !== void 0 ? { override } : {});
15115
+ return (await detector.detect()).profile;
15116
+ }
15117
+ /** Map the on-disk model-proposal queue to F7 dedup pairs (open→pending, rejected→rejected). */
15118
+ async lmlmDedupSource() {
15119
+ const proposals = await listProposals2(this.projectRoot, { kind: "model" });
15120
+ const pending = [];
15121
+ const rejected = [];
15122
+ for (const p of proposals) {
15123
+ if (p.kind !== "model") continue;
15124
+ const pair = {
15125
+ target: p.model.target.ollamaName,
15126
+ ...p.model.replaces ? { replaces: p.model.replaces.ollamaName } : {}
15127
+ };
15128
+ if (p.status === "open") pending.push(pair);
15129
+ else if (p.status === "rejected") rejected.push(pair);
15130
+ }
15131
+ return { pending, rejected };
15132
+ }
14408
15133
  async initLocalModelAndPipeline() {
14409
15134
  if (this.localResolvers.size > 0) {
14410
15135
  const backends = this.config.agent.backends ?? {};
@@ -14427,10 +15152,14 @@ ${messages}`);
14427
15152
  });
14428
15153
  this.localModelStatusUnsubscribes.push(unsubscribe);
14429
15154
  }
15155
+ if (this.poolStateStore !== null) {
15156
+ await this.poolStateStore.load();
15157
+ }
14430
15158
  for (const resolver of this.localResolvers.values()) {
14431
15159
  await resolver.start();
14432
15160
  }
14433
15161
  }
15162
+ this.startRefreshScheduler();
14434
15163
  this.pipeline = this.createIntelligencePipeline();
14435
15164
  this.server?.setPipeline(this.pipeline);
14436
15165
  }
@@ -14506,6 +15235,8 @@ ${messages}`);
14506
15235
  for (const resolver of this.localResolvers.values()) {
14507
15236
  resolver.stop();
14508
15237
  }
15238
+ this.refreshScheduler?.stop();
15239
+ this.refreshScheduler = null;
14509
15240
  if (this.maintenanceScheduler) {
14510
15241
  this.maintenanceScheduler.stop();
14511
15242
  this.maintenanceScheduler = null;