@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.d.mts +114 -4
- package/dist/index.d.ts +114 -4
- package/dist/index.js +771 -50
- package/dist/index.mjs +778 -47
- package/package.json +6 -5
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:
|
|
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
|
|
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: [${
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9274
|
+
sendJSON10(res, 200, { decision, def: { type: def.type } });
|
|
8891
9275
|
} catch (err) {
|
|
8892
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9559
|
+
sendJSON11(res, 409, { error: msg });
|
|
9176
9560
|
return;
|
|
9177
9561
|
}
|
|
9178
|
-
|
|
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
|
-
|
|
9568
|
+
sendJSON11(res, 200, list);
|
|
9185
9569
|
} catch {
|
|
9186
|
-
|
|
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
|
-
|
|
9577
|
+
sendJSON11(res, 404, { error: "Token not found" });
|
|
9194
9578
|
return;
|
|
9195
9579
|
}
|
|
9196
|
-
|
|
9580
|
+
sendJSON11(res, 200, { deleted: true });
|
|
9197
9581
|
} catch {
|
|
9198
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
9620
|
+
sendJSON12(res, 405, { error: "Method not allowed" });
|
|
9237
9621
|
return true;
|
|
9238
9622
|
}
|
|
9239
9623
|
if (!getStatus) {
|
|
9240
|
-
|
|
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
|
-
|
|
9629
|
+
sendJSON12(res, 503, { error: "Local backend not configured" });
|
|
9246
9630
|
return true;
|
|
9247
9631
|
}
|
|
9248
|
-
|
|
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
|
-
|
|
9639
|
+
sendJSON12(res, 405, { error: "Method not allowed" });
|
|
9256
9640
|
return true;
|
|
9257
9641
|
}
|
|
9258
9642
|
const statuses = getStatuses ? getStatuses() : [];
|
|
9259
|
-
|
|
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) =>
|
|
9998
|
-
|
|
9999
|
-
|
|
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;
|